diff --git a/full-stack/ngnix/workbench.js b/full-stack/ngnix/workbench.js index 57c41e6..63ed97b 100644 --- a/full-stack/ngnix/workbench.js +++ b/full-stack/ngnix/workbench.js @@ -8,6 +8,14 @@ document.addEventListener('DOMContentLoaded', function() { const uploadStatus = document.getElementById('upload-status'); const addPhotoCard = document.getElementById('add-photo-card'); + // Search elements + const searchInput = document.getElementById('search-input'); + const searchButton = document.getElementById('search-button'); + const searchResults = document.getElementById('search-results'); + + // Export elements + const exportXlsxButton = document.getElementById('export-xlsx-button'); + // Modal elements const modal = document.getElementById('photo-modal'); const modalImage = document.getElementById('modal-image'); @@ -44,6 +52,19 @@ document.addEventListener('DOMContentLoaded', function() { } }); + // Event listener for search button + searchButton.addEventListener('click', function() { + const query = searchInput.value.trim(); + if (query) { + searchPhotos(query); + } + }); + + // Event listener for export XLSX button + exportXlsxButton.addEventListener('click', function() { + exportToXlsx(); + }); + // Function to upload photo function uploadPhoto(file) { uploadStatus.textContent = 'Uploading...'; @@ -127,6 +148,83 @@ document.addEventListener('DOMContentLoaded', function() { modal.style.display = 'block'; } + // Function to search photos by coordinates or address + function searchPhotos(query) { + searchResults.innerHTML = '

Searching...

'; + + // Check if query is coordinates (lat,lon format) + const coordMatch = query.match(/^(-?\d+\.?\d*)\s*,\s*(-?\d+\.?\d*)$/); + + if (coordMatch) { + // Search by coordinates + const lat = parseFloat(coordMatch[1]); + const lon = parseFloat(coordMatch[2]); + + fetch('/api/search/by_coordinates', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ lat: lat, lon: lon, radius_km: 1.0 }), + credentials: 'include' + }) + .then(response => response.json()) + .then(data => { + displaySearchResults(data); + }) + .catch(error => { + searchResults.innerHTML = '

Error searching by coordinates.

'; + console.error('Search error:', error); + }); + } else { + // Search by address + fetch('/api/search/by_address', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ address: query }), + credentials: 'include' + }) + .then(response => response.json()) + .then(data => { + displaySearchResults(data); + }) + .catch(error => { + searchResults.innerHTML = '

Error searching by address.

'; + console.error('Search error:', error); + }); + } + } + + // Function to display search results + function displaySearchResults(results) { + if (results.length === 0) { + searchResults.innerHTML = '

No results found.

'; + return; + } + + let html = '

Search Results:

'; + results.forEach(result => { + html += ` +
+

Coordinates: ${result.coordinates.lat}, ${result.coordinates.lon}

+

Address: ${result.address || 'N/A'}

+

Distance: ${result.distance_km ? result.distance_km.toFixed(2) + ' km' : 'N/A'}

+

Processed: ${new Date(result.processed_at).toLocaleString()}

+
+ `; + }); + html += '
'; + + searchResults.innerHTML = html; + } + + // Function to export data to XLSX + function exportToXlsx() { + window.location.href = '/api/export/results/xlsx'; + } + // Load photos when page loads loadPhotos(); -}); \ No newline at end of file +}); diff --git a/src/api/app.py b/src/api/app.py index 2385cd4..c11d49d 100644 --- a/src/api/app.py +++ b/src/api/app.py @@ -136,6 +136,30 @@ class AsyncTaskResponse(BaseModel): message: str = "Задача принята в обработку" +class SearchByCoordinatesRequest(BaseModel): + """Модель запроса для поиска изображений по координатам""" + + lat: float + lon: float + radius_km: Optional[float] = 1.0 # Радиус поиска в километрах + + +class SearchByAddressRequest(BaseModel): + """Модель запроса для поиска изображений по адресу""" + + address: str + + +class SearchResult(BaseModel): + """Модель результата поиска изображений""" + + image_path: str + coordinates: CoordinateResult + address: Optional[str] = None + distance_km: Optional[float] = None + processed_at: str + + class TaskStatusResponse(BaseModel): """Модель ответа для статуса задачи""" @@ -345,6 +369,181 @@ async def get_latest_results(limit: int = 10): raise HTTPException(status_code=500, detail=f"Ошибка получения результатов: {str(e)}") +@app.post("/search/by_coordinates", response_model=List[SearchResult]) +async def search_by_coordinates(request: SearchByCoordinatesRequest): + """Поиск изображений по координатам""" + try: + db = SessionLocal() + + # Если радиус не задан, используем значение по умолчанию + radius_km = request.radius_km or 1.0 + + # Запрос к базе данных для поиска изображений в радиусе заданных координат + # Используем приближенную формулу для расчета расстояния между точками + query = text(""" + SELECT id, image_path, coordinates, address, processed_at, + (6371 * acos(cos(radians(:lat)) * cos(radians((coordinates->>'lat')::float)) + * cos(radians((coordinates->>'lon')::float) - radians(:lon)) + + sin(radians(:lat)) * sin(radians((coordinates->>'lat')::float)))) AS distance_km + FROM processing_results + WHERE coordinates IS NOT NULL + AND (coordinates->>'lat')::float IS NOT NULL + AND (coordinates->>'lon')::float IS NOT NULL + AND (6371 * acos(cos(radians(:lat)) * cos(radians((coordinates->>'lat')::float)) + * cos(radians((coordinates->>'lon')::float) - radians(:lon)) + + sin(radians(:lat)) * sin(radians((coordinates->>'lat')::float)))) <= :radius_km + ORDER BY distance_km ASC + LIMIT 50 + """) + + result = db.execute(query, { + "lat": request.lat, + "lon": request.lon, + "radius_km": radius_km + }) + rows = result.fetchall() + db.close() + + # Преобразуем результаты в формат ответа + search_results = [] + for row in rows: + coords = json.loads(row.coordinates) if isinstance(row.coordinates, str) else row.coordinates + search_results.append(SearchResult( + image_path=row.image_path, + coordinates=CoordinateResult(lat=coords.get("lat"), lon=coords.get("lon")), + address=row.address, + distance_km=float(row.distance_km) if row.distance_km is not None else None, + processed_at=row.processed_at.isoformat() if hasattr(row.processed_at, 'isoformat') else str(row.processed_at) + )) + + return search_results + except Exception as e: + raise HTTPException(status_code=500, detail=f"Ошибка поиска по координатам: {str(e)}") + + +@app.post("/search/by_address", response_model=List[SearchResult]) +async def search_by_address(request: SearchByAddressRequest): + """Поиск изображений по адресу""" + try: + # Сначала геокодируем адрес в координаты + from src.geo.geocoder import geocode_coordinates + import re + + # Простая попытка извлечь координаты из адреса, если они указаны в формате "lat,lon" + coord_match = re.match(r'^\s*(-?\d+\.?\d*)\s*,\s*(-?\d+\.?\d*)\s*$', request.address) + if coord_match: + lat, lon = float(coord_match.group(1)), float(coord_match.group(2)) + else: + # Пытаемся геокодировать адрес + # Для простоты возьмем координаты из строки адреса, если это возможно + # В реальной реализации здесь должен быть полноценный геокодер + raise HTTPException(status_code=501, detail="Геокодирование адресов пока не реализовано") + + # Выполняем поиск по координатам с небольшим радиусом + db = SessionLocal() + query = text(""" + SELECT id, image_path, coordinates, address, processed_at, + (6371 * acos(cos(radians(:lat)) * cos(radians((coordinates->>'lat')::float)) + * cos(radians((coordinates->>'lon')::float) - radians(:lon)) + + sin(radians(:lat)) * sin(radians((coordinates->>'lat')::float)))) AS distance_km + FROM processing_results + WHERE coordinates IS NOT NULL + AND (coordinates->>'lat')::float IS NOT NULL + AND (coordinates->>'lon')::float IS NOT NULL + AND (6371 * acos(cos(radians(:lat)) * cos(radians((coordinates->>'lat')::float)) + * cos(radians((coordinates->>'lon')::float) - radians(:lon)) + + sin(radians(:lat)) * sin(radians((coordinates->>'lat')::float)))) <= 1.0 + ORDER BY distance_km ASC + LIMIT 50 + """) + + result = db.execute(query, {"lat": lat, "lon": lon}) + rows = result.fetchall() + db.close() + + # Преобразуем результаты в формат ответа + search_results = [] + for row in rows: + coords = json.loads(row.coordinates) if isinstance(row.coordinates, str) else row.coordinates + search_results.append(SearchResult( + image_path=row.image_path, + coordinates=CoordinateResult(lat=coords.get("lat"), lon=coords.get("lon")), + address=row.address, + distance_km=float(row.distance_km) if row.distance_km is not None else None, + processed_at=row.processed_at.isoformat() if hasattr(row.processed_at, 'isoformat') else str(row.processed_at) + )) + + return search_results + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=f"Ошибка поиска по адресу: {str(e)}") + + +@app.get("/export/results/xlsx") +async def export_results_xlsx(): + """Экспорт результатов обработки в формате XLSX""" + try: + import pandas as pd + from io import BytesIO + from fastapi.responses import StreamingResponse + + # Получаем последние результаты из базы данных + db = SessionLocal() + query = text(""" + SELECT id, image_path, task_id, request_id, coordinates, address, + ocr_result, buildings, processed_at, error + FROM processing_results + ORDER BY processed_at DESC + LIMIT 1000 + """) + + result = db.execute(query) + rows = result.fetchall() + db.close() + + # Преобразуем данные в DataFrame + data = [] + for row in rows: + # Преобразуем JSON поля в строки для экспорта + coords = json.loads(row.coordinates) if isinstance(row.coordinates, str) else row.coordinates + ocr = json.loads(row.ocr_result) if isinstance(row.ocr_result, str) else row.ocr_result + buildings = json.loads(row.buildings) if isinstance(row.buildings, str) else row.buildings + + data.append({ + "ID": row.id, + "Путь к изображению": row.image_path, + "ID задачи": row.task_id, + "ID запроса": row.request_id, + "Широта": coords.get("lat") if coords else None, + "Долгота": coords.get("lon") if coords else None, + "Адрес": row.address, + "OCR результат": str(ocr) if ocr else None, + "Здания": str(buildings) if buildings else None, + "Дата обработки": row.processed_at.isoformat() if hasattr(row.processed_at, 'isoformat') else str(row.processed_at), + "Ошибка": row.error + }) + + df = pd.DataFrame(data) + + # Создаем буфер для XLSX файла + buffer = BytesIO() + with pd.ExcelWriter(buffer, engine='openpyxl') as writer: + df.to_excel(writer, index=False, sheet_name='Результаты обработки') + + buffer.seek(0) + + # Возвращаем файл как ответ + headers = { + 'Content-Disposition': 'attachment; filename="processing_results.xlsx"', + 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + } + + return StreamingResponse(buffer, headers=headers) + except Exception as e: + raise HTTPException(status_code=500, detail=f"Ошибка экспорта в XLSX: {str(e)}") + + @app.get("/model/info") async def model_info(): """Получить информацию о модели""" From 746682cc71f94cd7d0b8e8d783bb95ebc7d854a5 Mon Sep 17 00:00:00 2001 From: Lanmo Date: Tue, 30 Sep 2025 18:17:33 +0300 Subject: [PATCH 15/21] add trainer GeoClip --- .../2_2_train_model_geoclip.ipynb | 159 +++++++++++++++ src/data/datasets.py | 63 ++++++ src/models/loss_function.py | 27 +++ src/models/metrics.py | 55 ++++++ src/models/trainer.py | 184 ++++++++++++++++++ 5 files changed, 488 insertions(+) create mode 100644 notebooks/2_model_training/2_2_train_model_geoclip.ipynb create mode 100644 src/data/datasets.py create mode 100644 src/models/loss_function.py create mode 100644 src/models/metrics.py create mode 100644 src/models/trainer.py diff --git a/notebooks/2_model_training/2_2_train_model_geoclip.ipynb b/notebooks/2_model_training/2_2_train_model_geoclip.ipynb new file mode 100644 index 0000000..fd044bd --- /dev/null +++ b/notebooks/2_model_training/2_2_train_model_geoclip.ipynb @@ -0,0 +1,159 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "id": "4827dddb", + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "Epoch 1/3: 100%|██████████| 1/1 [00:06<00:00, 6.16s/batch]\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Epoch 1 completed, average loss: 2.439969\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "Epoch 2/3: 100%|██████████| 1/1 [00:06<00:00, 6.27s/batch]\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Epoch 2 completed, average loss: 2.107102\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "Epoch 3/3: 100%|██████████| 1/1 [00:08<00:00, 8.09s/batch]\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Epoch 3 completed, average loss: 1.840146\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "Encoding train coords: 100%|██████████| 1/1 [00:00<00:00, 49.60block/s]\n", + "Encoding test images: 100%|██████████| 7/7 [10:13<00:00, 87.65s/batch] \n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "=== Metrics ===\n", + "Coverage_with_coords_% 100.0\n", + "Top1_Distance_mean_m 19150.716598283827\n", + "Top1_Distance_median_m 18486.759429614227\n", + "Top1_Distance_std_m 9436.194419529222\n", + "Geo_Recall@1_≤25m 0.0\n", + "Geo_MRR@1_≤25m 0.0\n", + "Geo_Recall@5_≤25m 0.0\n", + "Geo_MRR@5_≤25m 0.0\n", + "Geo_Recall@10_≤25m 0.0\n", + "Geo_MRR@10_≤25m 0.0\n", + "Geo_Recall@1_≤50m 0.0\n", + "Geo_MRR@1_≤50m 0.0\n", + "Geo_Recall@5_≤50m 0.0\n", + "Geo_MRR@5_≤50m 0.0\n", + "Geo_Recall@10_≤50m 0.0\n", + "Geo_MRR@10_≤50m 0.0\n", + "Geo_Recall@1_≤100m 0.0012970168612191958\n", + "Geo_MRR@1_≤100m 0.0012970168612191958\n", + "Geo_Recall@5_≤100m 0.0025940337224383916\n", + "Geo_MRR@5_≤100m 0.0019455252918287938\n", + "Geo_Recall@10_≤100m 0.0025940337224383916\n", + "Geo_MRR@10_≤100m 0.0019455252918287938\n" + ] + } + ], + "source": [ + "from geoclip.model import image_encoder as ge_image\n", + "from geoclip.model import location_encoder as ge_loc\n", + "from models.trainer import train_and_evaluate\n", + "\n", + "image_encoder = ge_image.ImageEncoder()\n", + "loc_encoder = ge_loc.LocationEncoder()\n", + "\n", + "\n", + "\n", + "TRAIN_CSV = #Ссылка на train в таком формате path,lat,lon\n", + "TEST_CSV = #Ссылка на test в таком формате path,lat,lon\n", + "OUT_DIR = './checkpoints_notebook'\n", + "EPOCHS = 3\n", + "BATCH_SIZE = 32\n", + "LR = 3e-5\n", + "EMB_DIM = 512\n", + "USE_REPO_ENCODERS = False\n", + "USE_QUEUE = False\n", + "\n", + "\n", + "results = train_and_evaluate(\n", + " image_encoder=image_encoder,\n", + " loc_encoder=loc_encoder,\n", + " train_csv=TRAIN_CSV,\n", + " test_csv=TEST_CSV,\n", + " out_dir=OUT_DIR,\n", + " epochs=EPOCHS,\n", + " batch_size=BATCH_SIZE,\n", + " lr=LR,\n", + " use_queue=USE_QUEUE\n", + " )\n", + "\n", + "metrics = results['last_metrics']\n", + "print('\\n=== Metrics ===')\n", + "for k, v in metrics.items():\n", + " print(k, v)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d820eb69", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "hack_digital_transformation", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.11" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/src/data/datasets.py b/src/data/datasets.py new file mode 100644 index 0000000..b5c220c --- /dev/null +++ b/src/data/datasets.py @@ -0,0 +1,63 @@ +""" +Geo dataset and geodesic utility. +""" +import math +from typing import List + +import numpy as np +import pandas as pd +from PIL import Image +import torch +from torch.utils.data import Dataset + + +class GeoDataset(Dataset): + """A torch Dataset for geotagged images stored in a CSV with columns path,lat,lon.""" + def __init__(self, csv_file: str, preprocess=None, img_size: int = 224): + df = pd.read_csv(csv_file) + if not {'path', 'lat', 'lon'}.issubset(df.columns): + raise ValueError(f"CSV {csv_file} must contain 'path', 'lat' and 'lon' columns.") + self.df = df.reset_index(drop=True) + self.paths: List[str] = self.df['path'].astype(str).tolist() + self.lats: np.ndarray = self.df['lat'].astype(float).to_numpy() + self.lons: np.ndarray = self.df['lon'].astype(float).to_numpy() + self.preprocess = preprocess + self.img_size = img_size + if self.preprocess is None: + try: + from torchvision import transforms as T + self.fallback = T.Compose([ + T.Resize(int(self.img_size * 1.14)), + T.CenterCrop(self.img_size), + T.ToTensor(), + T.Normalize(mean=[0.48145466, 0.4578275, 0.40821073], + std=[0.26862954, 0.26130258, 0.27577711]) + ]) + except Exception: + self.fallback = None + + def __len__(self) -> int: + return len(self.paths) + + def __getitem__(self, idx: int): + path = self.paths[idx] + image = Image.open(path).convert('RGB') + if self.preprocess is not None: + tensor = self.preprocess(image) + if isinstance(tensor, torch.Tensor) and tensor.ndim == 4: + tensor = tensor.squeeze(0) + else: + if self.fallback is None: + raise RuntimeError("No preprocess provided and torchvision not available") + tensor = self.fallback(image) + gps = torch.tensor([self.lats[idx], self.lons[idx]], dtype=torch.float32) + return tensor, gps, path + + +def haversine_m(lat1: float, lon1: float, lat2: float, lon2: float) -> float: + R = 6371000.0 + lat1, lon1, lat2, lon2 = map(math.radians, (lat1, lon1, lat2, lon2)) + dlat = lat2 - lat1 + dlon = lon2 - lon1 + a = math.sin(dlat / 2) ** 2 + math.cos(lat1) * math.cos(lat2) * math.sin(dlon / 2) ** 2 + return 2 * R * math.asin(math.sqrt(a)) \ No newline at end of file diff --git a/src/models/loss_function.py b/src/models/loss_function.py new file mode 100644 index 0000000..349adc6 --- /dev/null +++ b/src/models/loss_function.py @@ -0,0 +1,27 @@ +from typing import Optional +import torch +import torch.nn.functional as F + + +def contrastive_loss_with_queue(im_vecs: torch.Tensor, loc_vecs: torch.Tensor, + q_tensor: Optional[torch.Tensor] = None, + temperature: float = 0.07) -> torch.Tensor: + device = im_vecs.device + B = im_vecs.shape[0] + + im_norm = F.normalize(im_vecs, p=2, dim=-1) + loc_norm = F.normalize(loc_vecs, p=2, dim=-1) + + if q_tensor is not None: + loc_concat = torch.cat([loc_norm, F.normalize(q_tensor, p=2, dim=-1)], dim=0) + else: + loc_concat = loc_norm + + logits_i2l = (im_norm @ loc_concat.t()) / temperature # [B, B+Q] + targets = torch.arange(B, device=device) + loss_i2l = F.cross_entropy(logits_i2l, targets) + + logits_l2i = (loc_norm @ im_norm.t()) / temperature # [B, B] + loss_l2i = F.cross_entropy(logits_l2i, targets) + + return 0.5 * (loss_i2l + loss_l2i) \ No newline at end of file diff --git a/src/models/metrics.py b/src/models/metrics.py new file mode 100644 index 0000000..73200dd --- /dev/null +++ b/src/models/metrics.py @@ -0,0 +1,55 @@ +from typing import List, Dict +import numpy as np +import pandas as pd + +from data.datasets import haversine_m + + +def geo_metrics_from_indices(df_test: pd.DataFrame, df_train: pd.DataFrame, + indices: np.ndarray, + ks: List[int] = [1, 5, 10], + radii_m: List[float] = [25.0, 50.0, 100.0]) -> Dict[str, float]: + metrics: Dict[str, float] = {} + valid_mask = (~df_test['lat'].isna()) & (~df_test['lon'].isna()) + coverage = 100.0 * float(valid_mask.sum()) / float(len(df_test)) if len(df_test) > 0 else 0.0 + metrics['Coverage_with_coords_%'] = round(coverage, 2) + + top1 = indices[:, 0] + dists = [] + for i in range(len(df_test)): + tlat = float(df_test.iloc[i]['lat']) + tlon = float(df_test.iloc[i]['lon']) + gidx = int(top1[i]) + glat = float(df_train.iloc[gidx]['lat']) + glon = float(df_train.iloc[gidx]['lon']) + dists.append(haversine_m(tlat, tlon, glat, glon)) + metrics['Top1_Distance_mean_m'] = float(np.mean(dists)) if len(dists) > 0 else float('nan') + metrics['Top1_Distance_median_m'] = float(np.median(dists)) if len(dists) > 0 else float('nan') + metrics['Top1_Distance_std_m'] = float(np.std(dists)) if len(dists) > 0 else float('nan') + + for rad in radii_m: + for K in ks: + hits = [] + rr_list = [] + for i in range(len(df_test)): + tlat = float(df_test.iloc[i]['lat']) + tlon = float(df_test.iloc[i]['lon']) + found_rank = None + max_rank = min(K, indices.shape[1]) + for rank_i in range(max_rank): + gidx = int(indices[i, rank_i]) + glat = float(df_train.iloc[gidx]['lat']) + glon = float(df_train.iloc[gidx]['lon']) + d = haversine_m(tlat, tlon, glat, glon) + if d <= rad: + found_rank = rank_i + 1 + break + if found_rank is None: + hits.append(0.0) + rr_list.append(0.0) + else: + hits.append(1.0) + rr_list.append(1.0 / found_rank) + metrics[f'Geo_Recall@{K}_≤{int(rad)}m'] = float(np.mean(hits)) + metrics[f'Geo_MRR@{K}_≤{int(rad)}m'] = float(np.mean(rr_list)) + return metrics \ No newline at end of file diff --git a/src/models/trainer.py b/src/models/trainer.py new file mode 100644 index 0000000..4fd0845 --- /dev/null +++ b/src/models/trainer.py @@ -0,0 +1,184 @@ +import os +import math +from typing import Optional, List, Dict + +import numpy as np +import pandas as pd +import torch +import torch.nn.functional as F +from torch.utils.data import DataLoader +from tqdm import tqdm +from PIL import Image +from data.datasets import GeoDataset +from models.loss_function import contrastive_loss_with_queue +from models.metrics import geo_metrics_from_indices +import faiss + +def train_and_evaluate( + image_encoder: torch.nn.Module, + loc_encoder: torch.nn.Module, + train_csv: str, + test_csv: str, + out_dir: str = './checkpoints', + epochs: int = 3, + batch_size: int = 128, + lr: float = 3e-5, + device: Optional[torch.device] = None, + eval_k: int = 10, + radii_m: List[float] = [25.0, 50.0, 100.0], + use_queue: bool = False, + queue_size: int = 65536, + amp: bool = True, + log_interval: int = 10 +) -> Dict[str, object]: + + os.makedirs(out_dir, exist_ok=True) + device = device or (torch.device('cuda') if torch.cuda.is_available() else torch.device('cpu')) + preprocess = getattr(image_encoder, 'preprocess_image', None) + + train_ds = GeoDataset(train_csv, preprocess=preprocess) + test_ds = GeoDataset(test_csv, preprocess=preprocess) + train_loader = DataLoader(train_ds, batch_size=batch_size, shuffle=True, num_workers=4, pin_memory=(device.type == 'cuda')) + + if isinstance(image_encoder, torch.nn.Module): + image_encoder = image_encoder.to(device) + img_forward = lambda x: image_encoder(x) + else: + img_forward = image_encoder + loc_encoder = loc_encoder.to(device) + + sample_img, sample_gps, _ = train_ds[0] + sample_inp = sample_img.unsqueeze(0).to(device) + + with torch.no_grad(): + img_emb_dim = img_forward(sample_inp).shape[1] + loc_emb_dim = loc_encoder(sample_gps.unsqueeze(0).to(device)).shape[1] + if img_emb_dim != loc_emb_dim: + raise RuntimeError(f"Image and location encoders must produce embeddings of the same dimension: {img_emb_dim} vs {loc_emb_dim}") + + emb_dim = img_emb_dim + queue = None + if use_queue: + queue = np.zeros((0, emb_dim), dtype=np.float32) + + params = [] + if isinstance(image_encoder, torch.nn.Module): + params += list(image_encoder.parameters()) + params += list(loc_encoder.parameters()) + optimizer = torch.optim.AdamW(params, lr=lr, weight_decay=1e-6) + + # AMP setup + use_amp = amp and device.type == 'cuda' + device_type = 'cuda' if device.type == 'cuda' else 'cpu' + scaler = torch.amp.GradScaler(enabled=use_amp and device.type == device_type) + + loss_history: List[float] = [] + for epoch in range(1, epochs + 1): + if isinstance(image_encoder, torch.nn.Module): + image_encoder.train() + loc_encoder.train() + running_loss = 0.0 + examples_seen = 0 + + pbar = tqdm(train_loader, desc=f"Epoch {epoch}/{epochs}", unit="batch") + for batch_idx, (imgs, gps, paths) in enumerate(pbar, 1): + imgs = imgs.to(device) + gps = gps.to(device) + optimizer.zero_grad() + with torch.amp.autocast(device_type=device_type, enabled=use_amp): + im_vecs = img_forward(imgs) + loc_vecs = loc_encoder(gps) + if use_queue and queue is not None and len(queue) > 0: + q_tensor = torch.from_numpy(queue).to(device) + else: + q_tensor = None + loss = contrastive_loss_with_queue(im_vecs, loc_vecs, q_tensor, temperature=0.07) + scaler.scale(loss).backward() + scaler.step(optimizer) + scaler.update() + + batch_size_actual = imgs.size(0) + running_loss += float(loss.item()) * batch_size_actual + examples_seen += batch_size_actual + + if use_queue: + with torch.no_grad(): + loc_norm = F.normalize(loc_vecs, p=2, dim=-1).cpu().numpy() + if queue is None or queue.size == 0: + queue = loc_norm.copy() + else: + queue = np.concatenate([queue, loc_norm], axis=0) + if queue.shape[0] > queue_size: + queue = queue[-queue_size:] + + if batch_idx % log_interval == 0: + mean_loss = running_loss / max(1, examples_seen) + pbar.set_postfix(mean_loss=f"{mean_loss:.6f}") + print(f"Epoch {epoch}, batch {batch_idx}, mean loss so far: {mean_loss:.6f}") + pbar.close() + + epoch_loss = running_loss / max(1, examples_seen) + print(f"Epoch {epoch} completed, average loss: {epoch_loss:.6f}") + loss_history.append(epoch_loss) + ckpt = { + 'epoch': epoch, + 'image_encoder': image_encoder.state_dict() if isinstance(image_encoder, torch.nn.Module) else None, + 'loc_encoder': loc_encoder.state_dict(), + 'optimizer': optimizer.state_dict(), + 'queue': queue + } + torch.save(ckpt, os.path.join(out_dir, f'checkpoint_epoch{epoch}.pth')) + + df_train = pd.read_csv(train_csv)[['lat', 'lon']].reset_index(drop=True) + df_test = pd.read_csv(test_csv)[['lat', 'lon']].reset_index(drop=True) + + with torch.no_grad(): + if isinstance(image_encoder, torch.nn.Module): + image_encoder.eval() + loc_encoder.eval() + + train_coords = df_train.to_numpy(dtype=np.float32) + loc_embs_list = [] + for i in tqdm(range(0, train_coords.shape[0], 1024), desc="Encoding train coords", unit="block"): + block = torch.from_numpy(train_coords[i:i + 1024]).to(device) + feats = loc_encoder(block) + loc_embs_list.append(F.normalize(feats, p=2, dim=-1).cpu().numpy().astype(np.float32)) + loc_embs = np.vstack(loc_embs_list) if len(loc_embs_list) > 0 else np.zeros((0, emb_dim), dtype=np.float32) + + test_image_paths = test_ds.paths + image_embs_list = [] + for i in tqdm(range(0, len(test_image_paths), 128), desc="Encoding test images", unit="batch"): + batch_paths = test_image_paths[i:i + 128] + tensors = [] + for p in batch_paths: + img = Image.open(p).convert('RGB') + if preprocess is not None: + t = preprocess(img) + if isinstance(t, torch.Tensor) and t.ndim == 4: + t = t.squeeze(0) + else: + from torchvision import transforms as T + t = T.Compose([ + T.Resize(256), + T.CenterCrop(224), + T.ToTensor(), + T.Normalize(mean=[0.48145466, 0.4578275, 0.40821073], + std=[0.26862954, 0.26130258, 0.27577711]) + ])(img) + tensors.append(t) + batch_t = torch.stack(tensors, dim=0).to(device) + feats = img_forward(batch_t) + image_embs_list.append(F.normalize(feats, p=2, dim=-1).cpu().numpy().astype(np.float32)) + image_embs = np.vstack(image_embs_list) if len(image_embs_list) > 0 else np.zeros((0, emb_dim), dtype=np.float32) + + dim = loc_embs.shape[1] + index = faiss.IndexFlatIP(dim) + index.add(loc_embs) + K = min(eval_k, loc_embs.shape[0]) + _, I = index.search(image_embs, K) + + metrics = geo_metrics_from_indices(df_test, df_train, I, ks=[1, 5, 10], radii_m=radii_m) + return { + 'loss_history': loss_history, + 'last_metrics': metrics + } \ No newline at end of file From b25c5e319da9f4029b35059eb79880a885321a44 Mon Sep 17 00:00:00 2001 From: "a.n.piskunov" Date: Wed, 1 Oct 2025 07:06:10 +0300 Subject: [PATCH 16/21] refactoring: update docs --- full-stack/ngnix/README.md | 98 ++++++++--------- src/data/database_builder.py | 12 +-- src/data/faiss_indexer.py | 77 +++++++++++--- src/engine/optuna_optimize.py | 55 ++++++++-- src/geo/download_images.py | 179 +++++++++++++++++++++++++------- src/models/cv_model.py | 114 ++++++++++++++++---- src/models/feature_extractor.py | 99 ++++++++++++++---- src/tasks/worker.py | 62 +++++++++-- src/utils/monitor_database.py | 8 +- src/utils/s3_optimize.py | 116 ++++++++++----------- src/utils/useful_functions.py | 24 ++--- src/utils/zip.py | 10 +- 12 files changed, 609 insertions(+), 245 deletions(-) diff --git a/full-stack/ngnix/README.md b/full-stack/ngnix/README.md index d322505..37ad5b2 100644 --- a/full-stack/ngnix/README.md +++ b/full-stack/ngnix/README.md @@ -1,84 +1,84 @@ -# Nginx Static File Server and Web Interface +# Статический файловый сервер Nginx и веб-интерфейс -This project contains a Docker container configuration for serving static HTML, CSS, and JavaScript files using nginx. It provides the web interface for the building detection application. +Этот проект содержит конфигурацию Docker контейнера для обслуживания статических HTML, CSS и JavaScript файлов с использованием nginx. Он предоставляет веб-интерфейс для приложения определения зданий. -## Files Included +## Включенные файлы -- `login.html` - Login page -- `register.html` - Registration page -- `workbench.html` - Main application interface -- `styles.css` - Stylesheet for all pages -- `login.js` - JavaScript for login functionality -- `register.js` - JavaScript for registration functionality -- `workbench.js` - JavaScript for main application functionality -- `logout.js` - JavaScript for logout functionality +- `login.html` - Страница входа +- `register.html` - Страница регистрации +- `workbench.html` - Основной интерфейс приложения +- `styles.css` - Таблица стилей для всех страниц +- `login.js` - JavaScript для функциональности входа +- `register.js` - JavaScript для функциональности регистрации +- `workbench.js` - JavaScript для основной функциональности приложения +- `logout.js` - JavaScript для функциональности выхода -## Docker Setup +## Настройка Docker -### Build the Docker Image +### Сборка образа Docker ```bash docker build -t nginx-static-server . ``` -### Run the Container +### Запуск контейнера ```bash docker run -d -p 8080:80 --name static-server nginx-static-server ``` -### Access the Pages +### Доступ к страницам -After running the container, you can access the pages at: -- Login page: http://localhost:8080/login.html -- Registration page: http://localhost:8080/register.html -- Workbench (main application): http://localhost:8080/workbench.html +После запуска контейнера вы можете получить доступ к страницам по адресам: +- Страница входа: http://localhost:8080/login.html +- Страница регистрации: http://localhost:8080/register.html +- Рабочая среда (основное приложение): http://localhost:8080/workbench.html -### Stop the Container +### Остановка контейнера ```bash docker stop static-server ``` -### Remove the Container +### Удаление контейнера ```bash docker rm static-server ``` -## Web Interface Features +## Возможности веб-интерфейса -The web interface provides the following functionality: +Веб-интерфейс предоставляет следующую функциональность: -### Authentication -- User registration and login -- Session management -- Logout functionality +### Аутентификация +- Регистрация и вход пользователей +- Управление сессиями +- Функциональность выхода -### Photo Management -- Upload photos for processing -- View uploaded photos in a grid layout -- View photo details in a modal window +### Управление фотографиями +- Загрузка фотографий для обработки +- Просмотр загруженных фотографий в виде сетки +- Просмотр деталей фотографии в модальном окне -### Building Detection -- Automatic coordinate and address detection for uploaded photos -- Display of detected buildings with bounding boxes -- OCR results display +### Определение зданий +- Автоматическое определение координат и адреса для загруженных фотографий +- Отображение распознанных зданий с ограничивающими рамками +- Отображение результатов OCR -### Search Functionality -- Search for photos by coordinates (latitude, longitude) -- Search for photos by address -- Display of search results with distance information +### Функциональность поиска +- Поиск фотографий по координатам (широта, долгота) +- Поиск фотографий по адресу +- Отображение результатов поиска с информацией о расстоянии -### Data Export -- Export processing results to XLSX format -- Download exported data +### Экспорт данных +- Экспорт результатов обработки в формат XLSX +- Загрузка экспортированных данных -## Configuration +## Конфигурация -The nginx server is configured to: -- Serve static files from the `/usr/share/nginx/html/` directory -- Listen on port 80 -- Include security headers -- Enable gzip compression for text-based files -- Proxy API requests to backend services +Сервер nginx настроен на: +- Обслуживание статических файлов из директории `/usr/share/nginx/html/` +- Прослушивание порта 80 +- Включение заголовков безопасности +- Включение сжатия gzip для текстовых файлов +- Проксирование API запросов к бэкенд сервисам diff --git a/src/data/database_builder.py b/src/data/database_builder.py index 6640ea1..6ffea77 100644 --- a/src/data/database_builder.py +++ b/src/data/database_builder.py @@ -41,15 +41,15 @@ def create_directories(): - data/processed: для хранения промежуточных данных и метаданных - data/index: для хранения FAISS индексов - Параметры + Parameters ---------- Отсутствуют - Возвращает + Returns ------- None - Примеры + Examples -------- >>> create_directories() Директории созданы @@ -67,16 +67,16 @@ def validate_s3_connection(): используя экземпляр s3_manager. Выполняет тестовую операцию для подтверждения работоспособности соединения. - Параметры + Parameters ---------- Отсутствуют - Возвращает + Returns ------- bool True, если подключение успешно установлено, иначе False. - Примеры + Examples -------- >>> if validate_s3_connection(): ... print("Подключение к S3 установлено") diff --git a/src/data/faiss_indexer.py b/src/data/faiss_indexer.py index 5dbea55..90c1800 100644 --- a/src/data/faiss_indexer.py +++ b/src/data/faiss_indexer.py @@ -37,8 +37,10 @@ def __init__(self, dimension: int = 2048) -> None: """ Инициализация FAISS индекса - Args: - dimension: Размерность вектора признаков + Parameters + ---------- + dimension : int, optional + Размерность вектора признаков (по умолчанию 2048) """ self.dimension: int = dimension self.index: Optional[faiss.Index] = None @@ -48,12 +50,24 @@ def create_index(self, features_dict: Dict[str, Dict[str, Any]], index_type: str """ Создание FAISS индекса из признаков - Args: - features_dict: Словарь признаков {s3_key: {"features": np.ndarray, ...}} - index_type: Тип индекса ("Flat" или "IVF") + Parameters + ---------- + features_dict : Dict[str, Dict[str, Any]] + Словарь признаков {s3_key: {"features": np.ndarray, ...}} + index_type : str, optional + Тип индекса ("Flat" или "IVF") (по умолчанию "IVF") - Returns: + Returns + ------- + int Количество проиндексированных изображений + + Examples + -------- + >>> indexer = FaissIndexer(dimension=2048) + >>> features_dict = {"image1.jpg": {"features": np.random.rand(2048)}} + >>> num_indexed = indexer.create_index(features_dict, index_type="IVF") + >>> print(f"Проиндексировано изображений: {num_indexed}") """ s3_keys: List[str] = [] features_list: List[np.ndarray] = [] @@ -87,12 +101,25 @@ def search_similar(self, query_features: np.ndarray, k: int = 10) -> List[Dict[s """ Поиск k наиболее похожих изображений - Args: - query_features: Вектор признаков для поиска - k: Количество похожих изображений для возврата + Parameters + ---------- + query_features : np.ndarray + Вектор признаков для поиска + k : int, optional + Количество похожих изображений для возврата (по умолчанию 10) - Returns: + Returns + ------- + List[Dict[str, Union[int, str, float]]] Список результатов поиска + + Examples + -------- + >>> indexer = FaissIndexer(dimension=2048) + >>> query_features = np.random.rand(2048) + >>> results = indexer.search_similar(query_features, k=5) + >>> for result in results: + ... print(f"Ранг: {result['rank']}, Расстояние: {result['distance']}") """ if self.index is None: raise ValueError("Индекс не инициализирован") @@ -120,9 +147,18 @@ def save_index(self, index_path: str, mapping_path: str) -> None: """ Сохранение индекса и маппинга - Args: - index_path: Путь для сохранения индекса - mapping_path: Путь для сохранения маппинга + Parameters + ---------- + index_path : str + Путь для сохранения индекса + mapping_path : str + Путь для сохранения маппинга + + Examples + -------- + >>> indexer = FaissIndexer(dimension=2048) + >>> indexer.create_index(features_dict) + >>> indexer.save_index("data/index/faiss_index.bin", "data/processed/image_mapping.pkl") """ os.makedirs(os.path.dirname(index_path), exist_ok=True) os.makedirs(os.path.dirname(mapping_path), exist_ok=True) @@ -140,9 +176,18 @@ def load_index(self, index_path: str, mapping_path: str) -> None: """ Загрузка индекса и маппинга - Args: - index_path: Путь к сохраненному индексу - mapping_path: Путь к сохраненному маппингу + Parameters + ---------- + index_path : str + Путь к сохраненному индексу + mapping_path : str + Путь к сохраненному маппингу + + Examples + -------- + >>> indexer = FaissIndexer(dimension=2048) + >>> indexer.load_index("data/index/faiss_index.bin", "data/processed/image_mapping.pkl") + >>> print(f"Размер загруженного индекса: {indexer.index.ntotal}") """ self.index = faiss.read_index(index_path) diff --git a/src/engine/optuna_optimize.py b/src/engine/optuna_optimize.py index d59053e..5834c94 100644 --- a/src/engine/optuna_optimize.py +++ b/src/engine/optuna_optimize.py @@ -49,8 +49,19 @@ def parse_args(): - """Парсинг аргументов командной строки""" - parser = argparse.ArgumentParser(description="OCR Model Training") + """ + Парсинг аргументов командной строки + + Returns + ------- + argparse.Namespace + Объект с распарсенными аргументами командной строки + + Examples + -------- + >>> args = parse_args() + >>> print(args.csv_path) + """ parser.add_argument( "--csv-path", type=str, default="data/processed_data/merged_data.csv", help="Путь к CSV файлу с данными" ) @@ -417,12 +428,24 @@ def find_file_by_pattern(directory: Path, pattern: str) -> Optional[Path]: Ищет файл в директории по шаблону имени. Возвращает Path к первому найденному файлу или None. - Args: - directory: Директория для поиска - pattern: Шаблон имени файла + Parameters + ---------- + directory : Path + Директория для поиска + pattern : str + Шаблон имени файла - Returns: + Returns + ------- + Path или None Path к найденному файлу или None + + Examples + -------- + >>> directory = Path("/path/to/search") + >>> file_path = find_file_by_pattern(directory, "config") + >>> if file_path: + ... print(f"Найден файл: {file_path}") """ if not directory.exists(): return None @@ -437,12 +460,24 @@ def find_dir_by_pattern(directory: Path, pattern: str) -> Optional[Path]: Ищет директорию по шаблону имени. Возвращает Path к первой найденной директории или None. - Args: - directory: Директория для поиска - pattern: Шаблон имени директории + Parameters + ---------- + directory : Path + Директория для поиска + pattern : str + Шаблон имени директории - Returns: + Returns + ------- + Path или None Path к найденной директории или None + + Examples + -------- + >>> directory = Path("/path/to/search") + >>> dir_path = find_dir_by_pattern(directory, "images") + >>> if dir_path: + ... print(f"Найдена директория: {dir_path}") """ if not directory.exists(): return None diff --git a/src/geo/download_images.py b/src/geo/download_images.py index 5c4e83a..55ca121 100644 --- a/src/geo/download_images.py +++ b/src/geo/download_images.py @@ -51,12 +51,24 @@ def split_bbox(bbox: List[float], grid_size: int = 2) -> List[List[float]]: """ Разбивает bbox на grid_size x grid_size частей - Args: - bbox: [min_lon, min_lat, max_lon, max_lat] - grid_size: количество частей по каждой оси - - Returns: + Parameters + ---------- + bbox : List[float] + [min_lon, min_lat, max_lon, max_lat] + grid_size : int, optional + количество частей по каждой оси (по умолчанию 2) + + Returns + ------- + List[List[float]] Список под-bbox'ов + + Examples + -------- + >>> bbox = [37.0, 55.0, 38.0, 56.0] + >>> sub_bboxes = BBoxSplitter.split_bbox(bbox, grid_size=2) + >>> print(len(sub_bboxes)) + 4 """ min_lon, min_lat, max_lon, max_lat = bbox @@ -83,14 +95,27 @@ def create_bbox_grid( """ Создает сетку bbox вокруг центральной точки - Args: - center_lat: широта центра - center_lon: долгота центра - grid_radius: радиус сетки (количество bbox в каждую сторону от центра) - bbox_size: размер каждого bbox в градусах - - Returns: + Parameters + ---------- + center_lat : float + широта центра + center_lon : float + долгота центра + grid_radius : int, optional + радиус сетки (количество bbox в каждую сторону от центра) (по умолчанию 2) + bbox_size : float, optional + размер каждого bbox в градусах (по умолчанию 0.02) + + Returns + ------- + List[List[float]] Список bbox'ов + + Examples + -------- + >>> bboxes = BBoxSplitter.create_bbox_grid(55.7558, 37.6176, grid_radius=1) + >>> print(len(bboxes)) + 9 """ bboxes = [] @@ -110,12 +135,24 @@ def calculate_optimal_grid_size(bbox: List[float], target_bbox_area: float = 0.0 """ Рассчитывает оптимальный размер сетки на основе площади bbox - Args: - bbox: [min_lon, min_lat, max_lon, max_lat] - target_bbox_area: целевая площадь каждого под-bbox (в квадратных градусах) + Parameters + ---------- + bbox : List[float] + [min_lon, min_lat, max_lon, max_lat] + target_bbox_area : float, optional + целевая площадь каждого под-bbox (в квадратных градусах) (по умолчанию 0.0004) - Returns: + Returns + ------- + int Оптимальный размер сетки + + Examples + -------- + >>> bbox = [37.0, 55.0, 38.0, 56.0] + >>> grid_size = BBoxSplitter.calculate_optimal_grid_size(bbox) + >>> print(grid_size) + 50 """ min_lon, min_lat, max_lon, max_lat = bbox bbox_area = (max_lon - min_lon) * (max_lat - min_lat) @@ -137,6 +174,30 @@ def __init__( max_workers: int = 10, cache_dir: Optional[str] = None, ): + """ + Инициализация MapillaryS3Client + + Parameters + ---------- + access_token : str + Токен доступа к Mapillary API + s3_manager : S3Manager + Менеджер для работы с S3 + max_workers : int, optional + Максимальное количество рабочих потоков (по умолчанию 10) + cache_dir : str, optional + Директория для кэширования данных (по умолчанию None) + + Examples + -------- + >>> s3_manager = S3Manager() + >>> client = MapillaryS3Client( + ... access_token="your_token", + ... s3_manager=s3_manager, + ... max_workers=5, + ... cache_dir="/tmp/mapillary_cache" + ... ) + """ self.access_token = access_token self.s3_manager = s3_manager self.max_workers = max_workers @@ -243,13 +304,26 @@ def get_images_in_bbox_batch( """ Пакетное получение изображений для нескольких bbox - Args: - bboxes: список bbox'ов - max_results_per_bbox: максимальное количество результатов на bbox - use_cache: использовать кэширование - - Returns: + Parameters + ---------- + bboxes : List[List[float]] + список bbox'ов + max_results_per_bbox : int, optional + максимальное количество результатов на bbox (по умолчанию 500) + use_cache : bool, optional + использовать кэширование (по умолчанию True) + + Returns + ------- + List[Dict] Объединенный список уникальных изображений + + Examples + -------- + >>> client = MapillaryS3Client(access_token="token", s3_manager=s3_manager) + >>> bboxes = [[37.0, 55.0, 37.5, 55.5], [37.5, 55.0, 38.0, 55.5]] + >>> images = client.get_images_in_bbox_batch(bboxes) + >>> print(len(images)) """ all_images = [] @@ -292,14 +366,28 @@ def get_images_for_large_area( """ Получение изображений для большого региона с автоматическим разбиением на части - Args: - bbox: основной bbox [min_lon, min_lat, max_lon, max_lat] - grid_size: размер сетки (если None, рассчитывается автоматически) - max_results_per_bbox: максимальное количество результатов на под-bbox - use_cache: использовать кэширование - - Returns: + Parameters + ---------- + bbox : List[float] + основной bbox [min_lon, min_lat, max_lon, max_lat] + grid_size : int, optional + размер сетки (если None, рассчитывается автоматически) (по умолчанию None) + max_results_per_bbox : int, optional + максимальное количество результатов на под-bbox (по умолчанию 500) + use_cache : bool, optional + использовать кэширование (по умолчанию True) + + Returns + ------- + List[Dict] Список уникальных изображений + + Examples + -------- + >>> client = MapillaryS3Client(access_token="token", s3_manager=s3_manager) + >>> bbox = [37.0, 55.0, 38.0, 56.0] + >>> images = client.get_images_for_large_area(bbox) + >>> print(len(images)) """ # Рассчитываем оптимальный размер сетки если не задан if grid_size is None: @@ -328,16 +416,31 @@ def get_images_around_point( """ Получение изображений вокруг центральной точки - Args: - center_lat: широта центра - center_lon: долгота центра - grid_radius: радиус сетки - bbox_size: размер каждого bbox в градусах - max_results_per_bbox: максимальное количество результатов на bbox - use_cache: использовать кэширование - - Returns: + Parameters + ---------- + center_lat : float + широта центра + center_lon : float + долгота центра + grid_radius : int, optional + радиус сетки (по умолчанию 2) + bbox_size : float, optional + размер каждого bbox в градусах (по умолчанию 0.02) + max_results_per_bbox : int, optional + максимальное количество результатов на bbox (по умолчанию 500) + use_cache : bool, optional + использовать кэширование (по умолчанию True) + + Returns + ------- + List[Dict] Список уникальных изображений + + Examples + -------- + >>> client = MapillaryS3Client(access_token="token", s3_manager=s3_manager) + >>> images = client.get_images_around_point(55.7558, 37.6176) + >>> print(len(images)) """ # Создаем сетку bbox вокруг точки bboxes = self.bbox_splitter.create_bbox_grid(center_lat, center_lon, grid_radius, bbox_size) diff --git a/src/models/cv_model.py b/src/models/cv_model.py index f27351b..5c2e0a9 100644 --- a/src/models/cv_model.py +++ b/src/models/cv_model.py @@ -40,14 +40,29 @@ class CVModel: """Модель компьютерного зрения для детекции зданий и определения координат""" def __init__(self): - """Инициализация модели CV""" + """ + Инициализация модели CV + + Examples + -------- + >>> model = CVModel() + >>> result = model.process_image("path/to/image.jpg") + """ self.ocr_model = OverlayOCR() self.feature_extractor = FeatureExtractor() self.indexer = None self._initialize_faiss_index() def _initialize_faiss_index(self): - """Инициализация FAISS индекса""" + """ + Инициализация FAISS индекса + + Examples + -------- + >>> model = CVModel() + >>> # FAISS индекс инициализируется автоматически при создании экземпляра + >>> print(model.indexer.index.ntotal) + """ try: logger.info("Инициализация FAISS индекса...") self.indexer = FaissIndexer(dimension=2048) @@ -61,11 +76,21 @@ def process_image(self, image_path: str) -> Dict: """ Обработка изображения: детекция зданий, определение координат, OCR - Args: - image_path: Путь к изображению + Parameters + ---------- + image_path : str + Путь к изображению - Returns: + Returns + ------- + Dict Словарь с результатами обработки + + Examples + -------- + >>> model = CVModel() + >>> result = model.process_image("path/to/image.jpg") + >>> print(result["coordinates"]) """ try: result = { @@ -123,11 +148,22 @@ def _estimate_coordinates(self, similar_images: List[Dict]) -> Optional[Dict]: """ Оценка координат на основе похожих изображений - Args: - similar_images: Список похожих изображений с результатами поиска + Parameters + ---------- + similar_images : List[Dict] + Список похожих изображений с результатами поиска - Returns: + Returns + ------- + Dict или None Словарь с координатами {lat, lon} или None + + Examples + -------- + >>> similar_images = [{"s3_key": "img1.jpg", "distance": 0.5}] + >>> coords = model._estimate_coordinates(similar_images) + >>> if coords: + ... print(f"Координаты: {coords['lat']}, {coords['lon']}") """ if not similar_images: return None @@ -167,11 +203,21 @@ def _get_image_coordinates_from_metadata(self, s3_key: str) -> Optional[Dict]: """ Получение координат изображения из метаданных S3 - Args: - s3_key: Ключ объекта в S3 + Parameters + ---------- + s3_key : str + Ключ объекта в S3 - Returns: + Returns + ------- + Dict или None Словарь с координатами {lat, lon} или None + + Examples + -------- + >>> coords = model._get_image_coordinates_from_metadata("images/123.jpg") + >>> if coords: + ... print(f"Координаты: {coords['lat']}, {coords['lon']}") """ try: # Получаем метаданные объекта из S3 @@ -191,11 +237,22 @@ def _geocode_coordinates(self, coordinates: Dict) -> Optional[str]: """ Получение адреса по координатам - Args: - coordinates: Словарь с координатами {lat, lon} + Parameters + ---------- + coordinates : Dict + Словарь с координатами {lat, lon} - Returns: + Returns + ------- + str или None Адрес в виде строки или None + + Examples + -------- + >>> coords = {"lat": 55.7558, "lon": 37.6176} + >>> address = model._geocode_coordinates(coords) + >>> if address: + ... print(f"Адрес: {address}") """ if coordinates and "lat" in coordinates and "lon" in coordinates: try: @@ -211,11 +268,20 @@ def _detect_buildings_placeholder(self, image_path: str) -> List[Dict]: Заглушка для детекции зданий В текущей архитектуре используется визуальный поиск, поэтому детекция не требуется - Args: - image_path: Путь к изображению + Parameters + ---------- + image_path : str + Путь к изображению - Returns: + Returns + ------- + List[Dict] Список обнаруженных зданий + + Examples + -------- + >>> buildings = model._detect_buildings_placeholder("path/to/image.jpg") + >>> print(len(buildings)) """ # В текущей архитектуре мы не детектируем здания напрямую, # а находим похожие изображения в базе @@ -223,5 +289,17 @@ def _detect_buildings_placeholder(self, image_path: str) -> List[Dict]: def create_cv_model() -> CVModel: - """Фабричная функция для создания экземпляра CVModel""" + """ + Фабричная функция для создания экземпляра CVModel + + Returns + ------- + CVModel + Экземпляр CVModel + + Examples + -------- + >>> model = create_cv_model() + >>> result = model.process_image("path/to/image.jpg") + """ return CVModel() diff --git a/src/models/feature_extractor.py b/src/models/feature_extractor.py index b655d43..c2a7e11 100644 --- a/src/models/feature_extractor.py +++ b/src/models/feature_extractor.py @@ -42,8 +42,10 @@ def __init__(self, device: Optional[str] = None) -> None: """ Инициализация экстрактора признаков - Args: - device: Устройство для вычислений (cuda/cpu) + Parameters + ---------- + device : str, optional + Устройство для вычислений (cuda/cpu) (по умолчанию None) """ # Определяем устройство self.device = device or ("cuda" if torch.cuda.is_available() else "cpu") @@ -71,11 +73,23 @@ def load_image_from_bytes(self, image_data: bytes) -> Optional[Image.Image]: """ Загрузка изображения из байтов - Args: - image_data: Байтовые данные изображения + Parameters + ---------- + image_data : bytes + Байтовые данные изображения - Returns: + Returns + ------- + Image.Image или None Изображение PIL или None в случае ошибки + + Examples + -------- + >>> with open("image.jpg", "rb") as f: + ... image_data = f.read() + >>> image = extractor.load_image_from_bytes(image_data) + >>> if image is not None: + ... print(f"Изображение загружено: {image.size}") """ try: image = Image.open(io.BytesIO(image_data)) @@ -90,11 +104,23 @@ def extract_features(self, image: Image.Image) -> Optional[np.ndarray]: """ Извлечение признаков из изображения - Args: - image: Изображение PIL + Parameters + ---------- + image : Image.Image + Изображение PIL - Returns: + Returns + ------- + np.ndarray или None Массив признаков или None в случае ошибки + + Examples + -------- + >>> from PIL import Image + >>> image = Image.open("image.jpg") + >>> features = extractor.extract_features(image) + >>> if features is not None: + ... print(f"Размер вектора признаков: {features.shape}") """ try: # Применяем трансформации @@ -118,11 +144,21 @@ def process_image_batch(self, image_batch: Dict[str, bytes]) -> Dict[str, Dict[s """ Обработка батча изображений {s3_key: image_data} - Args: - image_batch: Словарь с данными изображений + Parameters + ---------- + image_batch : Dict[str, bytes] + Словарь с данными изображений - Returns: + Returns + ------- + Dict[str, Dict[str, Any]] Словарь с результатами обработки + + Examples + -------- + >>> image_batch = {"image1.jpg": image_data1, "image2.jpg": image_data2} + >>> results = extractor.process_image_batch(image_batch) + >>> print(f"Обработано изображений: {len(results)}") """ start_time = time.time() batch_results: Dict[str, Dict[str, Any]] = {} @@ -151,11 +187,20 @@ def get_all_s3_images(self, prefix: str = "") -> List[str]: """ Получение списка всех изображений в S3 bucket - Args: - prefix: Префикс для фильтрации файлов + Parameters + ---------- + prefix : str, optional + Префикс для фильтрации файлов (по умолчанию "") - Returns: + Returns + ------- + List[str] Список ключей изображений + + Examples + -------- + >>> image_keys = extractor.get_all_s3_images(prefix="moscow/images/") + >>> print(f"Найдено изображений: {len(image_keys)}") """ print("Получение списка изображений из S3...") image_keys = s3_manager.list_files(prefix=prefix, file_extensions=[".jpg", ".jpeg", ".png", ".webp"]) @@ -166,11 +211,21 @@ def process_all_images(self, batch_size: int = 32) -> Tuple[Dict[str, Dict[str, """ Пакетная обработка всех изображений из S3 - Args: - batch_size: Размер батча для обработки + Parameters + ---------- + batch_size : int, optional + Размер батча для обработки (по умолчанию 32) - Returns: + Returns + ------- + Tuple[Dict[str, Dict[str, Any]], List[str]] Кортеж из словаря признаков и списка неудачных изображений + + Examples + -------- + >>> features_dict, failed_images = extractor.process_all_images(batch_size=16) + >>> print(f"Обработано изображений: {len(features_dict)}") + >>> print(f"Ошибок: {len(failed_images)}") """ print("Начало обработки всех изображений...") @@ -228,7 +283,15 @@ def get_processing_stats(self) -> Dict[str, int]: """ Получение статистики обработки - Returns: + Returns + ------- + Dict[str, int] Словарь со статистикой обработки + + Examples + -------- + >>> stats = extractor.get_processing_stats() + >>> print(f"Обработано изображений: {stats['processed']}") + >>> print(f"Ошибок: {stats['failed']}") """ return self.stats.copy() diff --git a/src/tasks/worker.py b/src/tasks/worker.py index 0fa7614..2f7c076 100644 --- a/src/tasks/worker.py +++ b/src/tasks/worker.py @@ -63,7 +63,19 @@ def get_cv_model(): - """Получение глобального экземпляра CV модели""" + """ + Получение глобального экземпляра CV модели + + Returns + ------- + CVModel + Экземпляр CV модели + + Examples + -------- + >>> model = get_cv_model() + >>> result = model.process_image("path/to/image.jpg") + """ global cv_model if cv_model is None: cv_model = CVModel() @@ -75,12 +87,22 @@ def process_image_task(self, image_path: str, request_id: str = None) -> dict: """ Асинхронная задача для обработки изображения - Args: - image_path: Путь к изображению - request_id: Идентификатор запроса (для отслеживания) + Parameters + ---------- + image_path : str + Путь к изображению + request_id : str, optional + Идентификатор запроса (для отслеживания) (по умолчанию None) - Returns: + Returns + ------- + dict Результат обработки изображения + + Examples + -------- + >>> result = process_image_task("path/to/image.jpg", "req_123") + >>> print(result["task_id"]) """ try: logger.info(f"Начало обработки изображения: {image_path}") @@ -120,8 +142,15 @@ def save_result_to_db(result: dict): """ Сохранение результата обработки в базу данных - Args: - result: Результат обработки изображения + Parameters + ---------- + result : dict + Результат обработки изображения + + Examples + -------- + >>> result = {"image_path": "path/to/image.jpg", "task_id": "task_123"} + >>> save_result_to_db(result) """ try: db = SessionLocal() @@ -187,12 +216,23 @@ def batch_process_images_task(self, image_paths: list, request_id: str = None) - """ Асинхронная задача для пакетной обработки изображений - Args: - image_paths: Список путей к изображениям - request_id: Идентификатор запроса (для отслеживания) + Parameters + ---------- + image_paths : list + Список путей к изображениям + request_id : str, optional + Идентификатор запроса (для отслеживания) (по умолчанию None) - Returns: + Returns + ------- + dict Сводный результат обработки + + Examples + -------- + >>> image_paths = ["path/to/image1.jpg", "path/to/image2.jpg"] + >>> result = batch_process_images_task(image_paths, "req_456") + >>> print(result["total_processed"]) """ try: logger.info(f"Начало пакетной обработки {len(image_paths)} изображений") diff --git a/src/utils/monitor_database.py b/src/utils/monitor_database.py index 345ea48..6a12c5a 100644 --- a/src/utils/monitor_database.py +++ b/src/utils/monitor_database.py @@ -39,16 +39,16 @@ def monitor_database(): - Метаданных сборки системы - Подключения к облачному хранилищу S3 - Параметры + Parameters ---------- - Отсутствуют + None - Вывод + Returns ------- None Выводит информацию о состоянии системы в стандартный поток вывода. - Примеры + Examples -------- >>> monitor_database() === МОНИТОРИНГ БАЗЫ ДАННЫХ === diff --git a/src/utils/s3_optimize.py b/src/utils/s3_optimize.py index c81b6f1..211053a 100644 --- a/src/utils/s3_optimize.py +++ b/src/utils/s3_optimize.py @@ -48,7 +48,7 @@ class S3Manager: включая параллельную загрузку и скачивание файлов, управление соединениями и сбор статистики операций. - Атрибуты + Attributes ---------- key_id : str Идентификатор ключа доступа к S3. @@ -76,7 +76,7 @@ def __init__( """ Инициализация менеджера S3. - Параметры + Parameters ---------- key_id : str, optional Идентификатор ключа доступа к S3. Если не указан, берется из переменной окружения AWS_ACCESS_KEY_ID. @@ -92,8 +92,8 @@ def __init__( chunk_size : int, optional Размер части файла для multipart операций в байтах (по умолчанию 8MB). - Исключения - ---------- + Raises + ------ ValueError Возникает, если не указаны обязательные параметры подключения к S3. """ @@ -121,13 +121,13 @@ def client(self): Создает и возвращает клиент S3 при первом обращении. При последующих обращениях возвращает уже созданный клиент. Также проверяет подключение к бакету. - Возвращает + Returns ------- boto3.Client Инициализированный клиент S3. - Исключения - ---------- + Raises + ------ Exception Возникает, если не удалось создать клиент или подключиться к бакету. """ @@ -158,7 +158,7 @@ def upload_bytes_parallel( Для файлов размером больше chunk_size выполняет параллельную загрузку частями. Для маленьких файлов использует обычную загрузку. - Параметры + Parameters ---------- data : bytes Данные для загрузки в виде байтов. @@ -169,12 +169,12 @@ def upload_bytes_parallel( metadata : dict, optional Дополнительные метаданные для объекта (по умолчанию None). - Возвращает + Returns ------- bool True, если загрузка прошла успешно, иначе False. - Примеры + Examples -------- >>> s3_manager = S3Manager() >>> data = b"some binary data" @@ -238,7 +238,7 @@ def _upload_part(self, upload_id: str, s3_key: str, part_number: int, data: byte Внутренний метод, используемый для загрузки отдельной части файла в рамках multipart upload операции. - Параметры + Parameters ---------- upload_id : str Идентификатор multipart upload сессии. @@ -249,13 +249,13 @@ def _upload_part(self, upload_id: str, s3_key: str, part_number: int, data: byte data : bytes Данные части файла для загрузки. - Возвращает + Returns ------- tuple Кортеж из номера части и ETag ответа от S3. - Исключения - ---------- + Raises + ------ Exception Возникает, если произошла ошибка при загрузке части файла. """ @@ -277,7 +277,7 @@ def upload_bytes( Загружает данные в виде байтов в указанный объект S3. Подходит для относительно небольших файлов, которые не требуют multipart загрузки. - Параметры + Parameters ---------- data : bytes Данные для загрузки в виде байтов. @@ -288,12 +288,12 @@ def upload_bytes( metadata : dict, optional Дополнительные метаданные для объекта (по умолчанию None). - Возвращает + Returns ------- bool True, если загрузка прошла успешно, иначе False. - Примеры + Examples -------- >>> s3_manager = S3Manager() >>> data = b"some binary data" @@ -331,7 +331,7 @@ def upload_file_parallel( использует multipart загрузку, для маленьких - стандартную загрузку. При необходимости может удалить исходный файл после успешной загрузки. - Параметры + Parameters ---------- file_path : str Путь к локальному файлу для загрузки. @@ -343,12 +343,12 @@ def upload_file_parallel( metadata : dict, optional Дополнительные метаданные для объекта (по умолчанию None). - Возвращает + Returns ------- bool True, если загрузка прошла успешно, иначе False. - Примеры + Examples -------- >>> s3_manager = S3Manager() s >>> success = s3_manager.upload_file_parallel("/path/to/local/file.txt", "path/in/s3/file.txt") @@ -398,7 +398,7 @@ def batch_upload_files( производительности. Каждый файл загружается независимо с использованием метода upload_file_parallel. - Параметры + Parameters ---------- file_paths : List[str] Список путей к локальным файлам для загрузки. @@ -410,7 +410,7 @@ def batch_upload_files( progress_callback : callable, optional Функция обратного вызова для отслеживания прогресса загрузки (по умолчанию None). - Возвращает + Returns ------- Dict Словарь с результатами загрузки, содержащий ключи: @@ -418,7 +418,7 @@ def batch_upload_files( - "failed": список кортежей (файл, сообщение об ошибке) для неудачных загрузок - "total": общее количество файлов для загрузки - Примеры + Examples -------- >>> s3_manager = S3Manager() >>> file_paths = ["/path/to/file1.txt", "/path/to/file2.txt"] @@ -474,7 +474,7 @@ def upload_image_data( различные форматы изображений (JPEG, PNG, GIF, WEBP) и автоматически конвертирует изображения в RGB при необходимости. - Параметры + Parameters ---------- image_data : Union[bytes, BinaryIO] Данные изображения в виде байтов или файлового объекта. @@ -489,12 +489,12 @@ def upload_image_data( metadata : dict, optional Дополнительные метаданные для объекта (по умолчанию None). - Возвращает + Returns ------- bool True, если загрузка прошла успешно, иначе False. - Примеры + Examples -------- >>> s3_manager = S3Manager() >>> with open("image.jpg", "rb") as f: @@ -581,17 +581,17 @@ def download_bytes(self, s3_key: str) -> Optional[bytes]: Загружает объект из S3 и возвращает его содержимое в виде байтов. Подходит для относительно небольших файлов, которые помещаются в память. - Параметры + Parameters ---------- s3_key : str Ключ объекта в S3 для загрузки. - Возвращает + Returns ------- Optional[bytes] Содержимое файла в виде байтов, или None в случае ошибки. - Примеры + Examples -------- >>> s3_manager = S3Manager() >>> data = s3_manager.download_bytes("path/to/file.bin") @@ -623,19 +623,19 @@ def download_file(self, s3_key: str, local_path: str) -> bool: Загружает объект из S3 и сохраняет его в локальный файл. Автоматически создает необходимые директории в пути к файлу. - Параметры + Parameters ---------- s3_key : str Ключ объекта в S3 для загрузки. local_path : str Путь к локальному файлу для сохранения данных. - Возвращает + Returns ------- bool True, если загрузка прошла успешно, иначе False. - Примеры + Examples -------- >>> s3_manager = S3Manager() >>> success = s3_manager.download_file("path/to/remote/file.txt", "/local/path/file.txt") @@ -669,17 +669,17 @@ def download_bytes_parallel(self, s3_key: str) -> Optional[bytes]: Для файлов размером больше chunk_size выполняет параллельную загрузку частями. Для маленьких файлов использует обычную загрузку. - Параметры + Parameters ---------- s3_key : str Ключ объекта в S3 для загрузки. - Возвращает + Returns ------- Optional[bytes] Содержимое файла в виде байтов, или None в случае ошибки. - Примеры + Examples -------- >>> s3_manager = S3Manager() >>> data = s3_manager.download_bytes_parallel("path/to/large_file.bin") @@ -735,7 +735,7 @@ def _download_part(self, s3_key: str, start_byte: int, end_byte: int, part_num: Внутренний метод, используемый для загрузки отдельной части файла в рамках multipart download операции. - Параметры + Parameters ---------- s3_key : str Ключ объекта в S3. @@ -746,13 +746,13 @@ def _download_part(self, s3_key: str, start_byte: int, end_byte: int, part_num: part_num : int Номер части файла. - Возвращает + Returns ------- tuple Кортеж из данных части файла и номера части. - Исключения - ---------- + Raises + ------ Exception Возникает, если произошла ошибка при загрузке части файла. """ @@ -774,7 +774,7 @@ def batch_download_files(self, s3_keys: List[str], local_dir: str = "", progress используя многопоточность для повышения производительности. Каждый файл загружается независимо с использованием метода download_file. - Параметры + Parameters ---------- s3_keys : List[str] Список ключей объектов в S3 для загрузки. @@ -783,7 +783,7 @@ def batch_download_files(self, s3_keys: List[str], local_dir: str = "", progress progress_callback : callable, optional Функция обратного вызова для отслеживания прогресса загрузки (по умолчанию None). - Возвращает + Returns ------- Dict Словарь с результатами загрузки, содержащий ключи: @@ -791,7 +791,7 @@ def batch_download_files(self, s3_keys: List[str], local_dir: str = "", progress - "failed": список кортежей (ключ, сообщение об ошибке) для неудачных загрузок - "total": общее количество файлов для загрузки - Примеры + Examples -------- >>> s3_manager = S3Manager() >>> s3_keys = ["path/to/file1.txt", "path/to/file2.txt"] @@ -843,20 +843,20 @@ def batch_download_bytes(self, s3_keys: List[str], progress_callback=None) -> Di многопоточность для повышения производительности. Каждый файл загружается независимо с использованием метода download_bytes. - Параметры + Parameters ---------- s3_keys : List[str] Список ключей объектов в S3 для загрузки. progress_callback : callable, optional Функция обратного вызова для отслеживания прогресса загрузки (по умолчанию None). - Возвращает + Returns ------- Dict[str, Optional[bytes]] Словарь, где ключи - это S3 ключи, а значения - данные файлов в виде байтов или None в случае ошибки загрузки конкретного файла. - Примеры + Examples -------- >>> s3_manager = S3Manager() >>> s3_keys = ["path/to/file1.txt", "path/to/file2.txt"] @@ -905,7 +905,7 @@ def list_files( и расширениям файлов. Поддерживает пагинацию для получения всех объектов в бакете или поддиректории. - Параметры + Parameters ---------- prefix : str, optional Префикс для фильтрации объектов (по умолчанию ""). @@ -914,12 +914,12 @@ def list_files( file_extensions : List[str], optional Список расширений файлов для фильтрации (по умолчанию None). - Возвращает + Returns ------- List[str] Список ключей объектов в S3, соответствующих критериям фильтрации. - Примеры + Examples -------- >>> s3_manager = S3Manager() >>> # Получить все файлы @@ -972,12 +972,12 @@ def get_file_info(self, s3_key: str) -> Optional[Dict]: Получает метаданные объекта в S3, включая размер, дату последнего изменения, тип контента и пользовательские метаданные. - Параметры + Parameters ---------- s3_key : str Ключ объекта в S3 для получения информации. - Возвращает + Returns ------- Optional[Dict] Словарь с информацией о файле, содержащий ключи: @@ -987,7 +987,7 @@ def get_file_info(self, s3_key: str) -> Optional[Dict]: - "metadata": пользовательские метаданные Возвращает None в случае ошибки. - Примеры + Examples -------- >>> s3_manager = S3Manager() >>> info = s3_manager.get_file_info("path/to/file.txt") @@ -1015,17 +1015,17 @@ def file_exists(self, s3_key: str) -> bool: Использует head_object операцию, которая возвращает метаданные объекта без его загрузки. - Параметры + Parameters ---------- s3_key : str Ключ объекта в S3 для проверки существования. - Возвращает + Returns ------- bool True, если файл существует, иначе False. - Примеры + Examples -------- >>> s3_manager = S3Manager() >>> if s3_manager.file_exists("path/to/file.txt"): @@ -1054,7 +1054,7 @@ def get_upload_stats(self) -> Dict: Статистика включает количество успешных и неудачных операций, а также общий объем загруженных данных. - Возвращает + Returns ------- Dict Словарь со статистикой загрузки, содержащий ключи: @@ -1062,7 +1062,7 @@ def get_upload_stats(self) -> Dict: - "failed": количество неудачных операций загрузки - "total_size": общий объем загруженных данных в байтах - Примеры + Examples -------- >>> s3_manager = S3Manager() >>> stats = s3_manager.get_upload_stats() @@ -1079,7 +1079,7 @@ def get_download_stats(self) -> Dict: Статистика включает количество успешных и неудачных операций, а также общий объем скачанных данных. - Возвращает + Returns ------- Dict Словарь со статистикой скачивания, содержащий ключи: @@ -1087,7 +1087,7 @@ def get_download_stats(self) -> Dict: - "failed": количество неудачных операций скачивания - "total_size": общий объем скачанных данных в байтах - Примеры + Examples -------- >>> s3_manager = S3Manager() >>> stats = s3_manager.get_download_stats() diff --git a/src/utils/useful_functions.py b/src/utils/useful_functions.py index 0a302fc..9e239f2 100644 --- a/src/utils/useful_functions.py +++ b/src/utils/useful_functions.py @@ -38,7 +38,7 @@ def move_and_remove_files(source_dir, destination_dir, remove_after_move=False): Если параметр remove_after_move установлен в True, то после перемещения исходная директория удаляется. - Параметры + Parameters ---------- source_dir : Path Путь к исходной директории. @@ -48,7 +48,7 @@ def move_and_remove_files(source_dir, destination_dir, remove_after_move=False): Флаг, указывающий на необходимость удаления исходной директории после перемещения (по умолчанию False). - Примеры + Examples -------- >>> from pathlib import Path >>> move_and_remove_files(Path('/path/to/source'), Path('/path/to/destination')) @@ -79,18 +79,18 @@ def extract_coordinates(coord_string): Функция извлекает широту и долготу из строки формата "coordinates=[широта, долгота]". - Параметры + Parameters ---------- coord_string : str Строка, содержащая координаты в формате "coordinates=[число, число]". - Возвращает + Returns ------- tuple Кортеж из двух float значений (широта, долгота) или (None, None), если координаты не найдены. - Примеры + Examples -------- >>> extract_coordinates("coordinates=[55.7558, 37.6173]") (55.7558, 37.6173) @@ -120,7 +120,7 @@ def merge_tables_with_tolerance( Для каждой записи в таблице target находится ближайшая запись в таблице real_data в пределах заданного максимального расстояния. - Параметры + Parameters ---------- target : pandas.DataFrame Таблица с данными, к которым будут присоединены данные из real_data. @@ -137,13 +137,13 @@ def merge_tables_with_tolerance( max_distance_meters : int, optional Максимальное расстояние в метрах для сопоставления записей (по умолчанию 100). - Возвращает + Returns ------- pandas.DataFrame Результирующая таблица с объединенными данными, отсортированная по расстоянию. - Исключения - ---------- + Raises + ------ ValueError Возникает, если указанные столбцы с координатами не найдены в соответствующих таблицах. """ @@ -199,19 +199,19 @@ def levenshtein_distance(string1: str, string2: str) -> int: операций (вставки, удаления или замены), необходимых для преобразования одной строки в другую. - Параметры + Parameters ---------- string1 : str Первая строка для сравнения. string2 : str Вторая строка для сравнения. - Возвращает + Returns ------- int Расстояние Левенштейна между строками. - Примеры + Examples -------- >>> levenshtein_distance('AATZ', 'AAAZ') 1 diff --git a/src/utils/zip.py b/src/utils/zip.py index 031f283..0f8c32a 100644 --- a/src/utils/zip.py +++ b/src/utils/zip.py @@ -33,7 +33,7 @@ def extract_zip_advanced(zip_path: str, extract_to: str, remove_after_extract: b обработкой ошибок. Поддерживает проверку целостности архива и опциональное удаление исходного архива после успешного извлечения. - Параметры + Parameters ---------- zip_path : str Путь к ZIP файлу, который нужно разархивировать. @@ -43,13 +43,13 @@ def extract_zip_advanced(zip_path: str, extract_to: str, remove_after_extract: b Флаг, указывающий на необходимость удаления архива после успешного извлечения (по умолчанию False). - Возвращает + Returns ------- list или None Список имен файлов, извлеченных из архива, или None в случае ошибки. - Исключения - ---------- + Raises + ------ FileNotFoundError Возникает, если указанный ZIP файл не найден. ValueError @@ -57,7 +57,7 @@ def extract_zip_advanced(zip_path: str, extract_to: str, remove_after_extract: b zipfile.BadZipFile Возникает, если архив поврежден или не является ZIP файлом. - Примеры + Examples -------- >>> file_list = extract_zip_advanced("archive.zip", "extracted_files") >>> if file_list is not None: From 10c9ac622995ff9b88e244f1062f25a5fee9f55f Mon Sep 17 00:00:00 2001 From: "a.n.piskunov" Date: Wed, 1 Oct 2025 09:03:03 +0300 Subject: [PATCH 17/21] fix: update api --- README.md | 1 - full-stack/FastAPI/Dockerfile | 13 ---- full-stack/FastAPI/main.py | 19 ----- full-stack/FastAPI/requirements.txt | 2 - full-stack/PhotoUploadService/photo_upload.py | 16 ++-- full-stack/docker-compose.yml | 21 ++---- full-stack/ngnix/styles.css | 51 +++++++++++++ full-stack/ngnix/workbench.js | 75 ++++++++++++++++++- src/api/app.py | 30 ++++++++ src/tasks/worker.py | 10 ++- 10 files changed, 175 insertions(+), 63 deletions(-) delete mode 100644 full-stack/FastAPI/Dockerfile delete mode 100644 full-stack/FastAPI/main.py delete mode 100644 full-stack/FastAPI/requirements.txt diff --git a/README.md b/README.md index 7823ba5..c87fb40 100644 --- a/README.md +++ b/README.md @@ -118,7 +118,6 @@ DATABASE_URL=postgresql://postgres:postgres@localhost:5432/building_detector ├── full-stack/ # Микросервисы приложения │ ├── AuthService/ # Сервис аутентификации │ ├── DB/ # Конфигурация базы данных -│ ├── FastAPI/ # Базовый API сервис │ ├── PhotoUploadService/ # Сервис загрузки фотографий │ ├── ngnix/ # Веб-сервер и фронтенд │ └── docker-compose.yml # Конфигурация Docker Compose diff --git a/full-stack/FastAPI/Dockerfile b/full-stack/FastAPI/Dockerfile deleted file mode 100644 index 700df1b..0000000 --- a/full-stack/FastAPI/Dockerfile +++ /dev/null @@ -1,13 +0,0 @@ -FROM python:3.11-slim - -WORKDIR /app - -# Установка зависимостей -COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt - -# Копирование кода приложения -COPY . . - -# Запуск FastAPI с помощью uvicorn -CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/full-stack/FastAPI/main.py b/full-stack/FastAPI/main.py deleted file mode 100644 index d68ec75..0000000 --- a/full-stack/FastAPI/main.py +++ /dev/null @@ -1,19 +0,0 @@ -from fastapi import FastAPI - -app = FastAPI() - -@app.get("/") -def read_root(): - return {"message": "Hello from FastAPI!"} - -@app.get("/api") -def read_api_root(): - return {"message": "Welcome to the API"} - -@app.get("/api/items") -def read_items(): - return {"items": ["item1", "item2", "item3"]} - -@app.get("/api/items/{item_id}") -def read_item(item_id: int): - return {"item_id": item_id, "name": f"Item {item_id}"} diff --git a/full-stack/FastAPI/requirements.txt b/full-stack/FastAPI/requirements.txt deleted file mode 100644 index 364e2ee..0000000 --- a/full-stack/FastAPI/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -fastapi -uvicorn[standard] diff --git a/full-stack/PhotoUploadService/photo_upload.py b/full-stack/PhotoUploadService/photo_upload.py index 9220179..fe2018f 100644 --- a/full-stack/PhotoUploadService/photo_upload.py +++ b/full-stack/PhotoUploadService/photo_upload.py @@ -17,10 +17,10 @@ # Environment variables DATABASE_URL = os.getenv("DATABASE_URL") -AWS_ACCESS_KEY_ID = os.getenv("S3_ACCESS_KEY") -AWS_SECRET_ACCESS_KEY = os.getenv("S3_SECRET_KEY") -AWS_ENDPOINT_URL = os.getenv("S3_ENDPOINT") -S3_BUCKET_NAME = os.getenv("S3_BUCKET") +AWS_ACCESS_KEY_ID = os.getenv("AWS_ACCESS_KEY_ID") +AWS_SECRET_ACCESS_KEY = os.getenv("AWS_SECRET_ACCESS_KEY") +AWS_ENDPOINT_URL = os.getenv("AWS_ENDPOINT_URL") +S3_BUCKET_NAME = os.getenv("AWS_BUCKET_NAME") # API endpoint for image processing IMAGE_PROCESSING_API_URL = os.getenv("IMAGE_PROCESSING_API_URL", "http://fastapi:8000") @@ -33,13 +33,14 @@ ) -def trigger_image_processing(image_path: str, request_id: str = None) -> bool: +def trigger_image_processing(image_path: str, request_id: str = None, photo_id: int = None) -> bool: """ Trigger image processing by calling the image processing API Args: image_path: Path to the image in S3 request_id: Optional request ID for tracking + photo_id: Optional photo ID for linking results Returns: True if processing was triggered successfully, False otherwise @@ -53,6 +54,9 @@ def trigger_image_processing(image_path: str, request_id: str = None) -> bool: if request_id: payload["request_id"] = request_id + if photo_id: + payload["photo_id"] = photo_id + # Call the image processing API response = requests.post( f"{IMAGE_PROCESSING_API_URL}/process_image_async", @@ -170,7 +174,7 @@ async def upload_photo( conn.close() # Trigger image processing - processing_triggered = trigger_image_processing(photo_url, str(photo_id)) + processing_triggered = trigger_image_processing(photo_url, str(photo_id), photo_id) return { "id": photo_id, diff --git a/full-stack/docker-compose.yml b/full-stack/docker-compose.yml index 3674a9e..3386111 100644 --- a/full-stack/docker-compose.yml +++ b/full-stack/docker-compose.yml @@ -6,22 +6,12 @@ services: ports: - "80:80" depends_on: - - fastapi + - auth - photo-upload - app networks: - app-network - fastapi: - build: ./FastAPI - ports: - - "8000" - depends_on: - - db - environment: - - DATABASE_URL=postgresql://appuser:apppassword@db:5432/appdb - networks: - - app-network app: build: .. @@ -74,10 +64,11 @@ services: environment: - DATABASE_URL=postgresql://appuser:apppassword@db:5432/appdb # Конфигурация S3 - определите эти переменные в файле .env - - S3_BUCKET=${S3_BUCKET} - - S3_ENDPOINT=${S3_ENDPOINT} - - S3_ACCESS_KEY=${S3_ACCESS_KEY} - - S3_SECRET_KEY=${S3_SECRET_KEY} + - AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID} + - AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY} + - AWS_ENDPOINT_URL=${AWS_ENDPOINT_URL} + - AWS_BUCKET_NAME=${AWS_BUCKET_NAME} + - IMAGE_PROCESSING_API_URL=http://app:8000 networks: - app-network diff --git a/full-stack/ngnix/styles.css b/full-stack/ngnix/styles.css index 567bd17..af99aff 100644 --- a/full-stack/ngnix/styles.css +++ b/full-stack/ngnix/styles.css @@ -363,6 +363,35 @@ body { width: 100%; } +.photo-status { + margin: 5px 0 0; + font-size: 12px; + text-align: center; + width: 100%; + padding: 2px 4px; + border-radius: 4px; +} + +.photo-status.success { + background-color: rgba(34, 197, 94, 0.15); + color: #22c55e; +} + +.photo-status.error { + background-color: rgba(239, 68, 68, 0.15); + color: #ef4444; +} + +.photo-status.processing { + background-color: rgba(59, 130, 246, 0.15); + color: #3b82f6; +} + +.photo-status.unknown { + background-color: rgba(156, 163, 175, 0.15); + color: #94a3b8; +} + .upload-card { cursor: pointer; transition: transform 120ms ease, box-shadow 120ms ease; @@ -448,6 +477,28 @@ body { height: 150px; } +.modal-error { + color: #ef4444; + font-weight: bold; +} + +.modal-coordinates { + color: #22c55e; +} + +.modal-address { + color: #3b82f6; +} + +.modal-processed { + color: #94a3b8; +} + +.modal-info { + color: #94a3b8; + font-style: italic; +} + @media (max-width: 768px) { .container-workbench { flex-direction: column; diff --git a/full-stack/ngnix/workbench.js b/full-stack/ngnix/workbench.js index 63ed97b..51fdd84 100644 --- a/full-stack/ngnix/workbench.js +++ b/full-stack/ngnix/workbench.js @@ -110,7 +110,33 @@ document.addEventListener('DOMContentLoaded', function() { }) .then(response => response.json()) .then(data => { - displayPhotos(data.photos); + // Для каждого фото запрашиваем результаты обработки + const photosWithResults = data.photos.map(photo => { + return fetch(`/api/results/photo/${photo.id}`, { + method: 'GET', + credentials: 'include' + }) + .then(response => { + if (response.ok) { + return response.json(); + } else { + return null; + } + }) + .then(result => { + return {...photo, processing_result: result}; + }) + .catch(error => { + console.error('Error loading processing result for photo:', photo.id, error); + return {...photo, processing_result: null}; + }); + }); + + // Ждем завершения всех запросов + Promise.all(photosWithResults) + .then(photos => { + displayPhotos(photos); + }); }) .catch(error => { console.error('Error loading photos:', error); @@ -127,9 +153,26 @@ document.addEventListener('DOMContentLoaded', function() { photos.forEach(photo => { const photoCard = document.createElement('div'); photoCard.className = 'photo-card'; + + // Determine processing status + let statusHtml = ''; + if (photo.processing_result) { + if (photo.processing_result.error) { + statusHtml = `

Ошибка обработки

`; + } else if (photo.processing_result.coordinates) { + const coords = photo.processing_result.coordinates; + statusHtml = `

Координаты: ${coords.lat?.toFixed(6)}, ${coords.lon?.toFixed(6)}

`; + } else { + statusHtml = `

Обработка...

`; + } + } else { + statusHtml = `

Статус неизвестен

`; + } + photoCard.innerHTML = ` ${photo.created_at} -

${photo.created_at}

+

${new Date(photo.created_at).toLocaleString()}

+ ${statusHtml} `; // Add click event to open modal @@ -144,7 +187,33 @@ document.addEventListener('DOMContentLoaded', function() { // Function to open photo in modal function openPhotoModal(photo) { modalImage.src = photo.photo_url; - modalCaption.innerHTML = `

${photo.created_at}

Uploaded: ${new Date(photo.created_at).toLocaleString()}

`; + + // Build caption with processing results + let captionHtml = `

${new Date(photo.created_at).toLocaleString()}

`; + captionHtml += `

Uploaded: ${new Date(photo.created_at).toLocaleString()}

`; + + if (photo.processing_result) { + if (photo.processing_result.error) { + captionHtml += ``; + } else { + if (photo.processing_result.coordinates) { + const coords = photo.processing_result.coordinates; + captionHtml += ``; + } + + if (photo.processing_result.address) { + captionHtml += ``; + } + + if (photo.processing_result.processed_at) { + captionHtml += ``; + } + } + } else { + captionHtml += ``; + } + + modalCaption.innerHTML = captionHtml; modal.style.display = 'block'; } diff --git a/src/api/app.py b/src/api/app.py index c11d49d..026b5e3 100644 --- a/src/api/app.py +++ b/src/api/app.py @@ -369,6 +369,36 @@ async def get_latest_results(limit: int = 10): raise HTTPException(status_code=500, detail=f"Ошибка получения результатов: {str(e)}") +@app.get("/results/photo/{photo_id}") +async def get_photo_results(photo_id: int): + """Получение результатов обработки для конкретного фото""" + try: + db = SessionLocal() + query = text( + """ + SELECT id, photo_id, image_path, task_id, request_id, coordinates, address, + ocr_result, buildings, processed_at, error + FROM processing_results + WHERE photo_id = :photo_id + ORDER BY processed_at DESC + LIMIT 1 + """ + ) + + result = db.execute(query, {"photo_id": photo_id}) + row = result.fetchone() + db.close() + + if row: + return dict(row) + else: + raise HTTPException(status_code=404, detail="Результаты обработки для этого фото не найдены") + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=f"Ошибка получения результатов: {str(e)}") + + @app.post("/search/by_coordinates", response_model=List[SearchResult]) async def search_by_coordinates(request: SearchByCoordinatesRequest): """Поиск изображений по координатам""" diff --git a/src/tasks/worker.py b/src/tasks/worker.py index 2f7c076..3258918 100644 --- a/src/tasks/worker.py +++ b/src/tasks/worker.py @@ -83,7 +83,7 @@ def get_cv_model(): @celery_app.task(bind=True) -def process_image_task(self, image_path: str, request_id: str = None) -> dict: +def process_image_task(self, image_path: str, request_id: str = None, photo_id: int = None) -> dict: """ Асинхронная задача для обработки изображения @@ -159,6 +159,7 @@ def save_result_to_db(result: dict): image_path = result.get("image_path", "") task_id = result.get("task_id", "") request_id = result.get("request_id", "") + photo_id = result.get("photo_id", None) coordinates = result.get("coordinates", {}) address = result.get("address", "") ocr_result = result.get("ocr_result", {}) @@ -175,10 +176,10 @@ def save_result_to_db(result: dict): query = text( """ INSERT INTO processing_results ( - image_path, task_id, request_id, coordinates, address, + photo_id, image_path, task_id, request_id, coordinates, address, ocr_result, buildings, processed_at, error ) VALUES ( - :image_path, :task_id, :request_id, :coordinates, :address, + :photo_id, :image_path, :task_id, :request_id, :coordinates, :address, :ocr_result, :buildings, :processed_at, :error ) """ @@ -187,6 +188,7 @@ def save_result_to_db(result: dict): db.execute( query, { + "photo_id": photo_id, "image_path": image_path, "task_id": task_id, "request_id": request_id, @@ -247,7 +249,7 @@ def batch_process_images_task(self, image_paths: list, request_id: str = None) - self.update_state(state="PROGRESS", meta={"current": i, "total": len(image_paths)}) # Обрабатываем изображение - result = process_image_task(image_path, request_id) + result = process_image_task(image_path, request_id, None) results.append(result) except Exception as e: From 134132be9f7f03fa8b7d0a48206800f5fb0b42da Mon Sep 17 00:00:00 2001 From: LizardAPN Date: Wed, 1 Oct 2025 09:46:28 +0300 Subject: [PATCH 18/21] fix: update cv_model --- cookies.txt | 4 ++++ full-stack/PhotoUploadService/photo_upload.py | 10 ++++++---- full-stack/docker-compose.yml | 20 +++++++++++++++++++ src/models/OCR_model.py | 15 +++++++++++--- src/models/cv_model.py | 17 +++++++++++----- src/models/feature_extractor.py | 14 +++++++++++-- src/tasks/worker.py | 4 ++-- 7 files changed, 68 insertions(+), 16 deletions(-) create mode 100644 cookies.txt diff --git a/cookies.txt b/cookies.txt new file mode 100644 index 0000000..c31d989 --- /dev/null +++ b/cookies.txt @@ -0,0 +1,4 @@ +# Netscape HTTP Cookie File +# https://curl.se/docs/http-cookies.html +# This file was generated by libcurl! Edit at your own risk. + diff --git a/full-stack/PhotoUploadService/photo_upload.py b/full-stack/PhotoUploadService/photo_upload.py index fe2018f..e323ff5 100644 --- a/full-stack/PhotoUploadService/photo_upload.py +++ b/full-stack/PhotoUploadService/photo_upload.py @@ -33,7 +33,7 @@ ) -def trigger_image_processing(image_path: str, request_id: str = None, photo_id: int = None) -> bool: +def trigger_image_processing(image_path: str, request_id: Optional[str] = None, photo_id: Optional[int] = None) -> bool: """ Trigger image processing by calling the image processing API @@ -55,7 +55,7 @@ def trigger_image_processing(image_path: str, request_id: str = None, photo_id: payload["request_id"] = request_id if photo_id: - payload["photo_id"] = photo_id + payload["photo_id"] = str(photo_id) # Call the image processing API response = requests.post( @@ -141,7 +141,7 @@ async def upload_photo( raise HTTPException(status_code=400, detail="No file provided") # Generate unique filename - file_extension = file.filename.split('.')[-1] if '.' in file.filename else '' + file_extension = file.filename.split('.')[-1] if file.filename and '.' in file.filename else '' unique_filename = f"{uuid.uuid4()}.{file_extension}" try: @@ -150,7 +150,7 @@ async def upload_photo( file.file, S3_BUCKET_NAME, unique_filename, - ExtraArgs={'ContentType': file.content_type} + ExtraArgs={'ContentType': file.content_type or 'application/octet-stream'} ) # Generate S3 URL @@ -166,6 +166,8 @@ async def upload_photo( ) result = cur.fetchone() + if result is None: + raise HTTPException(status_code=500, detail="Failed to insert photo into database") photo_id = result[0] created_at = result[1] diff --git a/full-stack/docker-compose.yml b/full-stack/docker-compose.yml index 3386111..e689987 100644 --- a/full-stack/docker-compose.yml +++ b/full-stack/docker-compose.yml @@ -72,6 +72,25 @@ services: networks: - app-network + worker: + build: .. + command: celery -A src.tasks.worker worker --loglevel=info + depends_on: + - redis + - db + environment: + - DATABASE_URL=postgresql://appuser:apppassword@db:5432/appdb + - REDIS_URL=redis://redis:6379/0 + - AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID} + - AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY} + - AWS_ENDPOINT_URL=${AWS_ENDPOINT_URL} + - AWS_BUCKET_NAME=${AWS_BUCKET_NAME} + volumes: + - ../data:/app/data + - ../logs:/app/logs + networks: + - app-network + db: build: ./DB ports: @@ -90,3 +109,4 @@ networks: driver: bridge volumes: pgdata: + redis_data: \ No newline at end of file diff --git a/src/models/OCR_model.py b/src/models/OCR_model.py index a4554a4..b45d4f5 100644 --- a/src/models/OCR_model.py +++ b/src/models/OCR_model.py @@ -186,13 +186,22 @@ def roi_auto_band(img): return img[y0:y1, :] # ---------- главный метод ---------- - def run_on_image(self, image_path: str) -> Tuple[str, str, str, float, str]: + def run_on_image(self, image) -> Tuple[str, str, str, float, str]: """ Возвращает: final, norm, joined, conf, best_roi_name """ - img = cv2.imread(image_path) - assert img is not None, f"Не удалось загрузить изображение: {image_path}" + # Преобразуем PIL Image в numpy array, если необходимо + if hasattr(image, 'convert'): + # Это PIL Image, конвертируем в RGB если нужно, затем в numpy array + if image.mode != "RGB": + image = image.convert("RGB") + img = np.array(image) + # Конвертируем из RGB в BGR (так как OpenCV использует BGR) + img = cv2.cvtColor(img, cv2.COLOR_RGB2BGR) + else: + # Предполагаем, что это уже numpy array + img = image rois = [ ("left_bottom", self.roi_left_bottom(img, 1 / 3, 1 / 4)), diff --git a/src/models/cv_model.py b/src/models/cv_model.py index 5c2e0a9..27be324 100644 --- a/src/models/cv_model.py +++ b/src/models/cv_model.py @@ -22,6 +22,7 @@ import logging from datetime import datetime from typing import Dict, List, Optional +import io import numpy as np from PIL import Image @@ -102,15 +103,20 @@ def process_image(self, image_path: str) -> Dict: "ocr_result": None, } - # Загружаем изображение - image = Image.open(image_path) + # Загружаем изображение из S3 + image_data = s3_manager.download_bytes(image_path) + if image_data is None: + raise FileNotFoundError(f"Не удалось загрузить изображение из S3: {image_path}") + + # Открываем изображение из байтов + image = Image.open(io.BytesIO(image_data)) if image.mode != "RGB": image = image.convert("RGB") # Извлекаем признаки изображения features = self.feature_extractor.extract_features(image) - if features is not None: + if features is not None and self.indexer is not None: # Поиск похожих изображений в базе similar_images = self.indexer.search_similar(features, k=5) @@ -127,7 +133,7 @@ def process_image(self, image_path: str) -> Dict: # Выполняем OCR try: - final, norm, joined, conf, roi_name = self.ocr_model.run_on_image(image_path) + final, norm, joined, conf, roi_name = self.ocr_model.run_on_image(image) result["ocr_result"] = { "final": final, "norm": norm, @@ -221,7 +227,8 @@ def _get_image_coordinates_from_metadata(self, s3_key: str) -> Optional[Dict]: """ try: # Получаем метаданные объекта из S3 - metadata = s3_manager.get_object_metadata(s3_key) + file_info = s3_manager.get_file_info(s3_key) + metadata = file_info.get("metadata", {}) if file_info else {} if metadata and "latitude" in metadata and "longitude" in metadata: lat = float(metadata["latitude"]) diff --git a/src/models/feature_extractor.py b/src/models/feature_extractor.py index c2a7e11..8b8162b 100644 --- a/src/models/feature_extractor.py +++ b/src/models/feature_extractor.py @@ -51,8 +51,18 @@ def __init__(self, device: Optional[str] = None) -> None: self.device = device or ("cuda" if torch.cuda.is_available() else "cpu") print(f"Используется устройство: {self.device}") - # Загружаем предобученную ResNet50 - self.model = models.resnet50(weights=ResNet50_Weights.IMAGENET1K_V2) + # Загружаем предобученную ResNet50 с обработкой ошибок + try: + self.model = models.resnet50(weights=ResNet50_Weights.IMAGENET1K_V2) + except Exception as e: + print(f"Ошибка загрузки весов модели из интернета: {e}") + print("Попытка загрузить модель без предобученных весов...") + try: + self.model = models.resnet50(weights=None) + except Exception as e2: + print(f"Ошибка загрузки модели без весов: {e2}") + raise RuntimeError("Не удалось загрузить модель ResNet50") + self.model = nn.Sequential(*list(self.model.children())[:-1]) self.model.eval() self.model.to(self.device) diff --git a/src/tasks/worker.py b/src/tasks/worker.py index 3258918..986a57f 100644 --- a/src/tasks/worker.py +++ b/src/tasks/worker.py @@ -176,10 +176,10 @@ def save_result_to_db(result: dict): query = text( """ INSERT INTO processing_results ( - photo_id, image_path, task_id, request_id, coordinates, address, + image_path, task_id, request_id, coordinates, address, ocr_result, buildings, processed_at, error ) VALUES ( - :photo_id, :image_path, :task_id, :request_id, :coordinates, :address, + :image_path, :task_id, :request_id, :coordinates, :address, :ocr_result, :buildings, :processed_at, :error ) """ From 86085a74393690732df2abe4af2f7b5a13ef7b0c Mon Sep 17 00:00:00 2001 From: "a.n.piskunov" Date: Wed, 1 Oct 2025 09:55:07 +0300 Subject: [PATCH 19/21] feat: add new model --- README.md | 4 +- pyproject.toml | 4 + requirements.txt | 3 +- src/models/cv_model.py | 235 ++++++----------------------------------- test_geoclip_model.py | 39 +++++++ 5 files changed, 80 insertions(+), 205 deletions(-) create mode 100644 test_geoclip_model.py diff --git a/README.md b/README.md index c87fb40..f4d0a1b 100644 --- a/README.md +++ b/README.md @@ -18,10 +18,8 @@ ### Основные компоненты - **Frontend** - веб-интерфейс на HTML/CSS/JavaScript -- **Computer Vision Model** - модель компьютерного зрения на основе ResNet50 -- **FAISS Index** - индекс для быстрого поиска похожих изображений +- **Computer Vision Model** - модель компьютерного зрения на основе GeoCLIP - **Geocoder** - сервис геокодирования для получения адресов по координатам -- **OCR** - оптический распознаватель текста для извлечения информации с изображений - **Celery** - система для асинхронной обработки задач ## Установка и запуск diff --git a/pyproject.toml b/pyproject.toml index a2f9012..8aff8da 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,10 @@ dependencies = [ "fastapi>=0.85.0", "uvicorn>=0.20.0", "mypy_boto3_s3>=1.26.0", + "geoclip>=0.2.0", + "torch>=2.8.0", + "torchvision>=0.23.0", + "faiss-cpu>=1.12.0", ] [project.optional-dependencies] diff --git a/requirements.txt b/requirements.txt index 9b7eb78..b0a630c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -15,6 +15,7 @@ boto3==1.40.35 botocore==1.40.35 python-dotenv==1.1.1 pyyaml==6.0.2 +geoclip==0.2.0 # FastAPI Core Dependencies fastapi==0.117.1 @@ -55,4 +56,4 @@ pytest-asyncio==1.2.0 pytest-cov==7.0.0 # Code Quality -ruff==0.13.2 \ No newline at end of file +ruff==0.13.2 diff --git a/src/models/cv_model.py b/src/models/cv_model.py index 27be324..22cadef 100644 --- a/src/models/cv_model.py +++ b/src/models/cv_model.py @@ -27,10 +27,9 @@ import numpy as np from PIL import Image -# Импортируем существующие компоненты -from .feature_extractor import FeatureExtractor -from .OCR_model import OverlayOCR -from src.data.faiss_indexer import FaissIndexer +from geoclip import ImageEncoder +import torch + from src.geo.geocoder import geocode_coordinates from src.utils.config import DATA_PATHS, s3_manager @@ -38,44 +37,26 @@ class CVModel: - """Модель компьютерного зрения для детекции зданий и определения координат""" + """Модель компьютерного зрения для детекции зданий и определения координат на основе GeoCLIP""" def __init__(self): """ - Инициализация модели CV + Инициализация модели CV с использованием GeoCLIP Examples -------- >>> model = CVModel() >>> result = model.process_image("path/to/image.jpg") """ - self.ocr_model = OverlayOCR() - self.feature_extractor = FeatureExtractor() - self.indexer = None - self._initialize_faiss_index() - - def _initialize_faiss_index(self): - """ - Инициализация FAISS индекса - - Examples - -------- - >>> model = CVModel() - >>> # FAISS индекс инициализируется автоматически при создании экземпляра - >>> print(model.indexer.index.ntotal) - """ - try: - logger.info("Инициализация FAISS индекса...") - self.indexer = FaissIndexer(dimension=2048) - self.indexer.load_index(DATA_PATHS["faiss_index"], DATA_PATHS["mapping_file"]) - logger.info("FAISS индекс инициализирован") - except Exception as e: - logger.error(f"Ошибка инициализации FAISS индекса: {e}") - raise + # Инициализируем GeoCLIP модель + self.image_encoder = ImageEncoder() + self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu") + self.image_encoder = self.image_encoder.to(self.device) + self.image_encoder.eval() def process_image(self, image_path: str) -> Dict: """ - Обработка изображения: детекция зданий, определение координат, OCR + Обработка изображения: определение координат с помощью GeoCLIP Parameters ---------- @@ -113,36 +94,32 @@ def process_image(self, image_path: str) -> Dict: if image.mode != "RGB": image = image.convert("RGB") - # Извлекаем признаки изображения - features = self.feature_extractor.extract_features(image) - - if features is not None and self.indexer is not None: - # Поиск похожих изображений в базе - similar_images = self.indexer.search_similar(features, k=5) - - # Определяем координаты на основе топ-N похожих изображений - coordinates = self._estimate_coordinates(similar_images) - result["coordinates"] = coordinates - - # Получаем адрес по координатам - if coordinates: - result["address"] = self._geocode_coordinates(coordinates) - - # Детектируем здания (заглушка, так как у нас визуальный поиск) - result["buildings"] = self._detect_buildings_placeholder(image_path) + # Предобрабатываем изображение для GeoCLIP + preprocessed_image = self.image_encoder.preprocess_image(image) + if preprocessed_image.ndim == 3: + preprocessed_image = preprocessed_image.unsqueeze(0) + + # Переносим изображение на устройство + preprocessed_image = preprocessed_image.to(self.device) + + # Получаем предсказания координат от GeoCLIP + with torch.no_grad(): + pred_coords = self.image_encoder(preprocessed_image) + # pred_coords имеет форму [1, 2] с широтой и долготой + lat, lon = pred_coords[0].cpu().numpy() + + # Сохраняем координаты в результате + result["coordinates"] = {"lat": float(lat), "lon": float(lon)} - # Выполняем OCR + # Получаем адрес по координатам try: - final, norm, joined, conf, roi_name = self.ocr_model.run_on_image(image) - result["ocr_result"] = { - "final": final, - "norm": norm, - "joined": joined, - "confidence": conf, - "roi_name": roi_name, - } + address = geocode_coordinates(lat, lon) + result["address"] = address except Exception as e: - logger.warning(f"Ошибка OCR для {image_path}: {e}") + logger.warning(f"Ошибка геокодирования координат {lat}, {lon}: {e}") + + # Детектируем здания (заглушка, так как у нас GeoCLIP) + result["buildings"] = [{"bbox": [0, 0, 100, 100], "confidence": 1.0, "area": 10000}] # Заглушка return result @@ -150,150 +127,6 @@ def process_image(self, image_path: str) -> Dict: logger.error(f"Ошибка обработки изображения {image_path}: {e}") raise - def _estimate_coordinates(self, similar_images: List[Dict]) -> Optional[Dict]: - """ - Оценка координат на основе похожих изображений - - Parameters - ---------- - similar_images : List[Dict] - Список похожих изображений с результатами поиска - - Returns - ------- - Dict или None - Словарь с координатами {lat, lon} или None - - Examples - -------- - >>> similar_images = [{"s3_key": "img1.jpg", "distance": 0.5}] - >>> coords = model._estimate_coordinates(similar_images) - >>> if coords: - ... print(f"Координаты: {coords['lat']}, {coords['lon']}") - """ - if not similar_images: - return None - - try: - # Получаем координаты из метаданных похожих изображений - coordinates_list = [] - - for img_result in similar_images: - s3_key = img_result["s3_key"] - # Здесь нужно извлечь координаты из метаданных S3 объекта - # или из отдельной базы данных с координатами - coords = self._get_image_coordinates_from_metadata(s3_key) - if coords: - # Взвешиваем координаты по степени схожести - weight = 1.0 / (1.0 + img_result["distance"]) - coordinates_list.append({"lat": coords["lat"], "lon": coords["lon"], "weight": weight}) - - if not coordinates_list: - return None - - # Вычисляем взвешенное среднее координат - total_weight = sum(coord["weight"] for coord in coordinates_list) - if total_weight == 0: - return None - - avg_lat = sum(coord["lat"] * coord["weight"] for coord in coordinates_list) / total_weight - avg_lon = sum(coord["lon"] * coord["weight"] for coord in coordinates_list) / total_weight - - return {"lat": avg_lat, "lon": avg_lon} - - except Exception as e: - logger.error(f"Ошибка оценки координат: {e}") - return None - - def _get_image_coordinates_from_metadata(self, s3_key: str) -> Optional[Dict]: - """ - Получение координат изображения из метаданных S3 - - Parameters - ---------- - s3_key : str - Ключ объекта в S3 - - Returns - ------- - Dict или None - Словарь с координатами {lat, lon} или None - - Examples - -------- - >>> coords = model._get_image_coordinates_from_metadata("images/123.jpg") - >>> if coords: - ... print(f"Координаты: {coords['lat']}, {coords['lon']}") - """ - try: - # Получаем метаданные объекта из S3 - file_info = s3_manager.get_file_info(s3_key) - metadata = file_info.get("metadata", {}) if file_info else {} - - if metadata and "latitude" in metadata and "longitude" in metadata: - lat = float(metadata["latitude"]) - lon = float(metadata["longitude"]) - return {"lat": lat, "lon": lon} - - return None - except Exception as e: - logger.warning(f"Не удалось получить координаты для {s3_key}: {e}") - return None - - def _geocode_coordinates(self, coordinates: Dict) -> Optional[str]: - """ - Получение адреса по координатам - - Parameters - ---------- - coordinates : Dict - Словарь с координатами {lat, lon} - - Returns - ------- - str или None - Адрес в виде строки или None - - Examples - -------- - >>> coords = {"lat": 55.7558, "lon": 37.6176} - >>> address = model._geocode_coordinates(coords) - >>> if address: - ... print(f"Адрес: {address}") - """ - if coordinates and "lat" in coordinates and "lon" in coordinates: - try: - address = geocode_coordinates(coordinates["lat"], coordinates["lon"]) - return address - except Exception as e: - logger.warning(f"Ошибка геокодирования координат {coordinates}: {e}") - return None - return None - - def _detect_buildings_placeholder(self, image_path: str) -> List[Dict]: - """ - Заглушка для детекции зданий - В текущей архитектуре используется визуальный поиск, поэтому детекция не требуется - - Parameters - ---------- - image_path : str - Путь к изображению - - Returns - ------- - List[Dict] - Список обнаруженных зданий - - Examples - -------- - >>> buildings = model._detect_buildings_placeholder("path/to/image.jpg") - >>> print(len(buildings)) - """ - # В текущей архитектуре мы не детектируем здания напрямую, - # а находим похожие изображения в базе - return [{"bbox": [0, 0, 100, 100], "confidence": 1.0, "area": 10000}] # Заглушка - def create_cv_model() -> CVModel: """ diff --git a/test_geoclip_model.py b/test_geoclip_model.py new file mode 100644 index 0000000..43c61e1 --- /dev/null +++ b/test_geoclip_model.py @@ -0,0 +1,39 @@ +import sys +from pathlib import Path + +# Добавляем путь к утилитам для корректной работы импортов +utils_path = Path(__file__).resolve().parent / "src" / "utils" +if str(utils_path) not in sys.path: + sys.path.insert(0, str(utils_path)) + +# Настраиваем пути проекта +try: + from path_resolver import setup_project_paths + + setup_project_paths() +except ImportError: + # Если path_resolver недоступен, добавляем необходимые пути вручную + src_path = Path(__file__).resolve().parent / "src" + paths_to_add = [src_path, src_path / "utils", src_path / "geo"] + for path in paths_to_add: + path_str = str(path) + if path.exists() and path_str not in sys.path: + sys.path.insert(0, path_str) + +from src.models.cv_model import CVModel + +def test_geoclip_model(): + """Тест инициализации модели GeoCLIP""" + try: + print("Инициализация модели GeoCLIP...") + model = CVModel() + print("Модель GeoCLIP успешно инициализирована!") + print(f"Тип модели: {type(model.image_encoder)}") + print("Тест пройден успешно!") + return True + except Exception as e: + print(f"Ошибка при инициализации модели GeoCLIP: {e}") + return False + +if __name__ == "__main__": + test_geoclip_model() From fbd43e50e5fbde7ab06b8781258065af01a3f3a2 Mon Sep 17 00:00:00 2001 From: "a.n.piskunov" Date: Wed, 1 Oct 2025 11:07:37 +0300 Subject: [PATCH 20/21] fix: cv model update --- src/models/cv_model.py | 137 +++++++++++++++++++++-------------------- 1 file changed, 71 insertions(+), 66 deletions(-) diff --git a/src/models/cv_model.py b/src/models/cv_model.py index 22cadef..c7d83f7 100644 --- a/src/models/cv_model.py +++ b/src/models/cv_model.py @@ -36,44 +36,52 @@ logger = logging.getLogger(__name__) -class CVModel: - """Модель компьютерного зрения для детекции зданий и определения координат на основе GeoCLIP""" - - def __init__(self): - """ - Инициализация модели CV с использованием GeoCLIP +from src.data.faiss_indexer import FaissIndexer # Импортируем ваш индексер - Examples - -------- - >>> model = CVModel() - >>> result = model.process_image("path/to/image.jpg") - """ - # Инициализируем GeoCLIP модель - self.image_encoder = ImageEncoder() - self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu") - self.image_encoder = self.image_encoder.to(self.device) - self.image_encoder.eval() +logger = logging.getLogger(__name__) - def process_image(self, image_path: str) -> Dict: +class CVModel: + """Модель компьютерного зрения на основе GeoCLIP с поиском по FAISS""" + + def __init__( + self, + faiss_index_path: str = "data/index/geoclip_faiss.bin", + mapping_path: str = "data/index/geoclip_mapping.pkl", + train_metadata_path: str = "data/train_metadata.csv" + ): """ - Обработка изображения: определение координат с помощью GeoCLIP - - Parameters - ---------- - image_path : str - Путь к изображению - - Returns - ------- - Dict - Словарь с результатами обработки - - Examples - -------- - >>> model = CVModel() - >>> result = model.process_image("path/to/image.jpg") - >>> print(result["coordinates"]) + Инициализация модели: загрузка GeoCLIP и FAISS индекса. """ + self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu") + self.image_encoder = ImageEncoder().to(self.device).eval() + + # Инициализируем FAISS + self.faiss_indexer = FaissIndexer(dimension=512) # Размер эмбеддинга GeoCLIP + self.faiss_indexer.load_index(faiss_index_path, mapping_path) + + # Загружаем метаданные (координаты по ID) + self.metadata = self._load_metadata(train_metadata_path) + + def _load_metadata(self, csv_path: str) -> Dict[str, Dict[str, float]]: + """Загружает метаданные: s3_key -> {lat, lon}""" + import pandas as pd + df = pd.read_csv(csv_path) + metadata = {} + for _, row in df.iterrows(): + metadata[row["s3_key"]] = {"lat": row["lat"], "lon": row["lon"]} + return metadata + + def _encode_image(self, image: Image.Image) -> np.ndarray: + """Кодирует изображение в эмбеддинг""" + tensor = self.image_encoder.preprocess_image(image) + if tensor.ndim == 3: + tensor = tensor.unsqueeze(0) # [C,H,W] -> [1,C,H,W] + tensor = tensor.to(self.device) + with torch.no_grad(): + emb = self.image_encoder(tensor).cpu().numpy() + return emb.astype("float32") + + def process_image(self, image_path: str, is_local: bool = False) -> Dict: try: result = { "image_path": image_path, @@ -84,42 +92,39 @@ def process_image(self, image_path: str) -> Dict: "ocr_result": None, } - # Загружаем изображение из S3 - image_data = s3_manager.download_bytes(image_path) - if image_data is None: - raise FileNotFoundError(f"Не удалось загрузить изображение из S3: {image_path}") - - # Открываем изображение из байтов - image = Image.open(io.BytesIO(image_data)) - if image.mode != "RGB": - image = image.convert("RGB") - - # Предобрабатываем изображение для GeoCLIP - preprocessed_image = self.image_encoder.preprocess_image(image) - if preprocessed_image.ndim == 3: - preprocessed_image = preprocessed_image.unsqueeze(0) - - # Переносим изображение на устройство - preprocessed_image = preprocessed_image.to(self.device) - - # Получаем предсказания координат от GeoCLIP - with torch.no_grad(): - pred_coords = self.image_encoder(preprocessed_image) - # pred_coords имеет форму [1, 2] с широтой и долготой - lat, lon = pred_coords[0].cpu().numpy() - - # Сохраняем координаты в результате - result["coordinates"] = {"lat": float(lat), "lon": float(lon)} - - # Получаем адрес по координатам + # Загрузка изображения + if is_local: + image = Image.open(image_path).convert("RGB") + else: + image_data = s3_manager.download_bytes(image_path) + if image_data is None: + raise FileNotFoundError(f"Не удалось загрузить изображение: {image_path}") + image = Image.open(io.BytesIO(image_data)).convert("RGB") + + # Кодирование + query_emb = self._encode_image(image) + + # Поиск ближайшего + results = self.faiss_indexer.search_similar(query_emb, k=1) + if not results: + raise ValueError("Не найдено ближайших изображений") + + nearest_s3_key = results[0]["s3_key"] + coords = self.metadata.get(nearest_s3_key) + if not coords: + raise ValueError(f"Нет координат для {nearest_s3_key}") + + result["coordinates"] = {"lat": coords["lat"], "lon": coords["lon"]} + + # Геокодирование try: - address = geocode_coordinates(lat, lon) + address = geocode_coordinates(coords["lat"], coords["lon"]) result["address"] = address except Exception as e: - logger.warning(f"Ошибка геокодирования координат {lat}, {lon}: {e}") + logger.warning(f"Ошибка геокодирования: {e}") - # Детектируем здания (заглушка, так как у нас GeoCLIP) - result["buildings"] = [{"bbox": [0, 0, 100, 100], "confidence": 1.0, "area": 10000}] # Заглушка + # Заглушка для зданий + result["buildings"] = [{"bbox": [0, 0, 100, 100], "confidence": 1.0, "area": 10000}] return result From 5782b57c25fea9e82e04b7a1408861842eec50bf Mon Sep 17 00:00:00 2001 From: LizardAPN Date: Wed, 1 Oct 2025 13:29:07 +0300 Subject: [PATCH 21/21] feat: merge cv model with backend --- full-stack/docker-compose.yml | 11 +- pyproject.toml | 2 +- requirements.txt | 2 +- src/api/app.py | 2 +- src/data/faiss_indexer.py | 240 +++++++++------------------- src/models/cv_model.py | 290 ++++++++++++++++++++++++++-------- src/tasks/worker.py | 1 - 7 files changed, 314 insertions(+), 234 deletions(-) diff --git a/full-stack/docker-compose.yml b/full-stack/docker-compose.yml index e689987..bf726ea 100644 --- a/full-stack/docker-compose.yml +++ b/full-stack/docker-compose.yml @@ -12,7 +12,6 @@ services: networks: - app-network - app: build: .. ports: @@ -29,9 +28,13 @@ services: - AWS_BUCKET_NAME=${AWS_BUCKET_NAME} - YANDEX_GEOCODER_API_KEY=${YANDEX_GEOCODER_API_KEY} - GOOGLE_GEOCODER_API_KEY=${GOOGLE_GEOCODER_API_KEY} + - YANDEX_GEOCODER_API_KEY=${YANDEX_GEOCODER_API_KEY} + - TRANSFORMERS_OFFLINE=1 + - HF_HUB_OFFLINE=1 volumes: - ../data:/app/data - ../logs:/app/logs + - /home/lizardapn/.cache/huggingface/hub/:/root/.cache/huggingface/hub/ networks: - app-network @@ -63,10 +66,10 @@ services: - db environment: - DATABASE_URL=postgresql://appuser:apppassword@db:5432/appdb - # Конфигурация S3 - определите эти переменные в файле .env - AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID} - AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY} - AWS_ENDPOINT_URL=${AWS_ENDPOINT_URL} + - YANDEX_GEOCODER_API_KEY=${YANDEX_GEOCODER_API_KEY} - AWS_BUCKET_NAME=${AWS_BUCKET_NAME} - IMAGE_PROCESSING_API_URL=http://app:8000 networks: @@ -85,9 +88,13 @@ services: - AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY} - AWS_ENDPOINT_URL=${AWS_ENDPOINT_URL} - AWS_BUCKET_NAME=${AWS_BUCKET_NAME} + - YANDEX_GEOCODER_API_KEY=${YANDEX_GEOCODER_API_KEY} + - TRANSFORMERS_OFFLINE=1 + - HF_HUB_OFFLINE=1 volumes: - ../data:/app/data - ../logs:/app/logs + - /home/lizardapn/.cache/huggingface/hub/:/root/.cache/huggingface/hub/ networks: - app-network diff --git a/pyproject.toml b/pyproject.toml index 8aff8da..678ed57 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,7 +19,7 @@ dependencies = [ "fastapi>=0.85.0", "uvicorn>=0.20.0", "mypy_boto3_s3>=1.26.0", - "geoclip>=0.2.0", + "geoclip==1.2.0", "torch>=2.8.0", "torchvision>=0.23.0", "faiss-cpu>=1.12.0", diff --git a/requirements.txt b/requirements.txt index b0a630c..921774d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -15,7 +15,7 @@ boto3==1.40.35 botocore==1.40.35 python-dotenv==1.1.1 pyyaml==6.0.2 -geoclip==0.2.0 +geoclip==1.2.0 # FastAPI Core Dependencies fastapi==0.117.1 diff --git a/src/api/app.py b/src/api/app.py index 026b5e3..40014aa 100644 --- a/src/api/app.py +++ b/src/api/app.py @@ -1,6 +1,6 @@ import sys from pathlib import Path - +import os # Добавляем путь к утилитам для корректной работы импортов utils_path = Path(__file__).resolve().parent.parent / "utils" if str(utils_path) not in sys.path: diff --git a/src/data/faiss_indexer.py b/src/data/faiss_indexer.py index 90c1800..275a17a 100644 --- a/src/data/faiss_indexer.py +++ b/src/data/faiss_indexer.py @@ -30,170 +30,82 @@ from src.utils.config import FAISS_CONFIG -class FaissIndexer: - """Класс для работы с FAISS индексом для поиска похожих изображений""" - - def __init__(self, dimension: int = 2048) -> None: - """ - Инициализация FAISS индекса - - Parameters - ---------- - dimension : int, optional - Размерность вектора признаков (по умолчанию 2048) - """ - self.dimension: int = dimension - self.index: Optional[faiss.Index] = None - self.image_mapping: Dict[int, Dict[str, Any]] = {} - - def create_index(self, features_dict: Dict[str, Dict[str, Any]], index_type: str = "IVF") -> int: - """ - Создание FAISS индекса из признаков - - Parameters - ---------- - features_dict : Dict[str, Dict[str, Any]] - Словарь признаков {s3_key: {"features": np.ndarray, ...}} - index_type : str, optional - Тип индекса ("Flat" или "IVF") (по умолчанию "IVF") - - Returns - ------- - int - Количество проиндексированных изображений - - Examples - -------- - >>> indexer = FaissIndexer(dimension=2048) - >>> features_dict = {"image1.jpg": {"features": np.random.rand(2048)}} - >>> num_indexed = indexer.create_index(features_dict, index_type="IVF") - >>> print(f"Проиндексировано изображений: {num_indexed}") - """ - s3_keys: List[str] = [] - features_list: List[np.ndarray] = [] - - print("Подготовка данных для FAISS...") - for i, (s3_key, data) in enumerate(tqdm(features_dict.items())): - s3_keys.append(s3_key) - features_list.append(data["features"]) - - self.image_mapping[i] = {"s3_key": s3_key, "features": data["features"]} - features_matrix = np.array(features_list).astype("float32") - print(f"Размер матрицы признаков: {features_matrix.shape}") - - # Создание индекса - if index_type == "Flat": - self.index = faiss.IndexFlatL2(self.dimension) - elif index_type == "IVF": - quantizer = faiss.IndexFlatL2(self.dimension) - self.index = faiss.IndexIVFFlat(quantizer, self.dimension, FAISS_CONFIG["nlist"], faiss.METRIC_L2) - - print("Обучение FAISS индекса...") - self.index.train(features_matrix) - - print("Добавление данных в индекс...") - self.index.add(features_matrix) - - return len(features_matrix) - - def search_similar(self, query_features: np.ndarray, k: int = 10) -> List[Dict[str, Union[int, str, float]]]: - """ - Поиск k наиболее похожих изображений - - Parameters - ---------- - query_features : np.ndarray - Вектор признаков для поиска - k : int, optional - Количество похожих изображений для возврата (по умолчанию 10) - - Returns - ------- - List[Dict[str, Union[int, str, float]]] - Список результатов поиска - - Examples - -------- - >>> indexer = FaissIndexer(dimension=2048) - >>> query_features = np.random.rand(2048) - >>> results = indexer.search_similar(query_features, k=5) - >>> for result in results: - ... print(f"Ранг: {result['rank']}, Расстояние: {result['distance']}") - """ +class FaissIndexer: + """FAISS индекс для поиска похожих изображений по эмбеддингам""" + + def __init__(self, dimension: int = 512): + self.dimension = dimension + self.index = None + self.mapping = {} # index_id -> metadata + self.reverse_mapping = {} # s3_key -> index_id + + def load_index(self, index_path: str, mapping_path: str): + """Загружает FAISS индекс и маппинг""" + try: + if os.path.exists(index_path): + self.index = faiss.read_index(index_path) + logger.info(f"FAISS индекс загружен: {index_path}") + else: + logger.warning(f"FAISS индекс не найден: {index_path}") + return + + # Загружаем маппинг + if os.path.exists(mapping_path): + df = pd.read_csv(mapping_path) + for _, row in df.iterrows(): + index_id = int(row['index_id']) + self.mapping[index_id] = { + 's3_key': row['s3_key'], + 'lat': row.get('lat'), + 'lon': row.get('lon') + } + self.reverse_mapping[row['s3_key']] = index_id + logger.info(f"Загружено {len(self.mapping)} записей маппинга") + else: + logger.warning(f"Файл маппинга не найден: {mapping_path}") + + except Exception as e: + logger.error(f"Ошибка загрузки FAISS индекса: {e}") + + def search_similar(self, query_embedding: np.ndarray, k: int = 5) -> List[Dict]: + """Поиск k ближайших соседей""" if self.index is None: - raise ValueError("Индекс не инициализирован") - - query_features = query_features.astype("float32").reshape(1, -1) - - distances, indices = self.index.search(query_features, k) - - results: List[Dict[str, Union[int, str, float]]] = [] - for i, (distance, idx) in enumerate(zip(distances[0], indices[0])): - if idx in self.image_mapping: - results.append( - { + logger.error("FAISS индекс не загружен") + return [] + + try: + # Нормализуем запрос для косинусного расстояния + query_norm = self._l2_normalize(query_embedding) + + # Поиск в индексе + scores, indices = self.index.search(query_norm, k) + + results = [] + for i, (score, idx) in enumerate(zip(scores[0], indices[0])): + if idx in self.mapping: + metadata = self.mapping[idx] + results.append({ + "s3_key": metadata['s3_key'], + "similarity": float(score), "rank": i + 1, - "s3_key": self.image_mapping[idx]["s3_key"], - "distance": float(distance), - "similarity_score": 1 / (1 + distance), - "index_id": int(idx), - } - ) - - return results - - def save_index(self, index_path: str, mapping_path: str) -> None: - """ - Сохранение индекса и маппинга - - Parameters - ---------- - index_path : str - Путь для сохранения индекса - mapping_path : str - Путь для сохранения маппинга - - Examples - -------- - >>> indexer = FaissIndexer(dimension=2048) - >>> indexer.create_index(features_dict) - >>> indexer.save_index("data/index/faiss_index.bin", "data/processed/image_mapping.pkl") - """ - os.makedirs(os.path.dirname(index_path), exist_ok=True) - os.makedirs(os.path.dirname(mapping_path), exist_ok=True) - - if self.index is not None: - faiss.write_index(self.index, index_path) - - with open(mapping_path, "wb") as f: - pickle.dump(self.image_mapping, f) - - print(f"Индекс сохранен: {index_path}") - print(f"Маппинг сохранен: {mapping_path}") - - def load_index(self, index_path: str, mapping_path: str) -> None: - """ - Загрузка индекса и маппинга - - Parameters - ---------- - index_path : str - Путь к сохраненному индексу - mapping_path : str - Путь к сохраненному маппингу - - Examples - -------- - >>> indexer = FaissIndexer(dimension=2048) - >>> indexer.load_index("data/index/faiss_index.bin", "data/processed/image_mapping.pkl") - >>> print(f"Размер загруженного индекса: {indexer.index.ntotal}") - """ - self.index = faiss.read_index(index_path) - - with open(mapping_path, "rb") as f: - self.image_mapping = pickle.load(f) - - print(f"Индекс загружен: {index_path}") - if self.index is not None: - print(f"Размер индекса: {self.index.ntotal}") + "lat": metadata.get('lat'), + "lon": metadata.get('lon') + }) + + return results + + except Exception as e: + logger.error(f"Ошибка поиска в FAISS: {e}") + return [] + + def _l2_normalize(self, x: np.ndarray, eps: float = 1e-12) -> np.ndarray: + """L2 нормализация векторов""" + if x.ndim == 1: + x = x.reshape(1, -1) + norm = np.linalg.norm(x, ord=2, axis=1, keepdims=True) + return x / np.maximum(norm, eps) + + def get_index_size(self) -> int: + """Возвращает размер индекса""" + return self.index.ntotal if self.index else 0 \ No newline at end of file diff --git a/src/models/cv_model.py b/src/models/cv_model.py index c7d83f7..302bbde 100644 --- a/src/models/cv_model.py +++ b/src/models/cv_model.py @@ -9,7 +9,6 @@ # Настраиваем пути проекта try: from path_resolver import setup_project_paths - setup_project_paths() except ImportError: # Если path_resolver недоступен, добавляем необходимые пути вручную @@ -19,14 +18,16 @@ path_str = str(path) if path.exists() and path_str not in sys.path: sys.path.insert(0, path_str) + import logging from datetime import datetime from typing import Dict, List, Optional import io - +import os import numpy as np +import pandas as pd from PIL import Image - +import faiss from geoclip import ImageEncoder import torch @@ -36,52 +37,185 @@ logger = logging.getLogger(__name__) -from src.data.faiss_indexer import FaissIndexer # Импортируем ваш индексер +class FaissIndexer: + """FAISS индекс для поиска похожих изображений""" + + def __init__(self, dimension: int = 512): + self.dimension = dimension + self.index = None + self.mapping = {} # index_id -> s3_key + self.reverse_mapping = {} # s3_key -> index_id + + def load_index(self, index_path: str, mapping_path: str): + """Загружает FAISS индекс и маппинг""" + try: + if os.path.exists(index_path): + self.index = faiss.read_index(index_path) + logger.info(f"FAISS индекс загружен: {index_path}") + else: + logger.warning(f"FAISS индекс не найден: {index_path}") + return + + # Загружаем маппинг с проверкой наличия колонок + if os.path.exists(mapping_path): + df = pd.read_csv(mapping_path) + logger.info(f"Колонки в файле маппинга: {df.columns.tolist()}") + + # Проверяем наличие необходимых колонок + if 'index_id' in df.columns and 's3_key' in df.columns: + for _, row in df.iterrows(): + self.mapping[row['index_id']] = row['s3_key'] + self.reverse_mapping[row['s3_key']] = row['index_id'] + logger.info(f"Загружено {len(self.mapping)} записей маппинга") + else: + logger.error(f"В файле маппинга отсутствуют необходимые колонки. Найдены: {df.columns.tolist()}") + + except Exception as e: + logger.error(f"Ошибка загрузки FAISS индекса: {e}") + + def search_similar(self, query_embedding: np.ndarray, k: int = 5) -> List[Dict]: + """Поиск k ближайших соседей""" + if self.index is None: + logger.error("FAISS индекс не загружен") + return [] + + try: + # Нормализуем запрос для косинусного расстояния + query_norm = self._l2_normalize(query_embedding) + + # Поиск в индексе + scores, indices = self.index.search(query_norm, k) + + results = [] + for i, (score, idx) in enumerate(zip(scores[0], indices[0])): + if idx in self.mapping: + results.append({ + "s3_key": self.mapping[idx], + "similarity": float(score), + "rank": i + 1 + }) + + return results + + except Exception as e: + logger.error(f"Ошибка поиска в FAISS: {e}") + return [] + + def _l2_normalize(self, x: np.ndarray, eps: float = 1e-12) -> np.ndarray: + """L2 нормализация векторов""" + if x.ndim == 1: + x = x.reshape(1, -1) + norm = np.linalg.norm(x, ord=2, axis=1, keepdims=True) + return x / np.maximum(norm, eps) -logger = logging.getLogger(__name__) class CVModel: """Модель компьютерного зрения на основе GeoCLIP с поиском по FAISS""" def __init__( self, - faiss_index_path: str = "data/index/geoclip_faiss.bin", - mapping_path: str = "data/index/geoclip_mapping.pkl", - train_metadata_path: str = "data/train_metadata.csv" + faiss_index_path: str = "data/index/faiss_index.bin", + mapping_path: str = "data/index/image_mapping.csv", + train_metadata_path: str = "data/processed_data/moscow_images.csv" ): """ Инициализация модели: загрузка GeoCLIP и FAISS индекса. """ self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu") + logger.info(f"Используется устройство: {self.device}") + + # Инициализация GeoCLIP self.image_encoder = ImageEncoder().to(self.device).eval() - - # Инициализируем FAISS - self.faiss_indexer = FaissIndexer(dimension=512) # Размер эмбеддинга GeoCLIP + logger.info("GeoCLIP модель инициализирована") + + # Инициализация FAISS индексера + self.faiss_indexer = FaissIndexer(dimension=512) self.faiss_indexer.load_index(faiss_index_path, mapping_path) - - # Загружаем метаданные (координаты по ID) + + # Загружаем метаданные (координаты по s3_key) self.metadata = self._load_metadata(train_metadata_path) + logger.info(f"Загружено {len(self.metadata)} записей метаданных") def _load_metadata(self, csv_path: str) -> Dict[str, Dict[str, float]]: """Загружает метаданные: s3_key -> {lat, lon}""" - import pandas as pd - df = pd.read_csv(csv_path) metadata = {} - for _, row in df.iterrows(): - metadata[row["s3_key"]] = {"lat": row["lat"], "lon": row["lon"]} + try: + if os.path.exists(csv_path): + df = pd.read_csv(csv_path) + logger.info(f"Колонки в файле метаданных: {df.columns.tolist()}") + + # Определяем названия колонок + s3_key_col = None + lat_col = None + lon_col = None + + # Ищем подходящие колонки + for col in df.columns: + col_lower = col.lower() + if 'key' in col_lower or 'path' in col_lower or 'file' in col_lower: + s3_key_col = col + elif 'lat' in col_lower: + lat_col = col + elif 'lon' in col_lower: + lon_col = col + + # Если не нашли автоматически, используем первые три колонки + if not s3_key_col and len(df.columns) >= 3: + s3_key_col = df.columns[0] + lat_col = df.columns[1] + lon_col = df.columns[2] + logger.info(f"Используем колонки по умолчанию: {s3_key_col}, {lat_col}, {lon_col}") + + if s3_key_col and lat_col and lon_col: + for _, row in df.iterrows(): + try: + s3_key = str(row[s3_key_col]) + lat = float(row[lat_col]) + lon = float(row[lon_col]) + metadata[s3_key] = {"lat": lat, "lon": lon} + except (ValueError, TypeError) as e: + logger.warning(f"Пропуск строки с невалидными данными: {row}") + continue + + logger.info(f"Метаданные загружены из {csv_path}") + else: + logger.error(f"Не удалось определить колонки в файле метаданных. Доступные колонки: {df.columns.tolist()}") + + else: + logger.warning(f"Файл метаданных не найден: {csv_path}") + + except Exception as e: + logger.error(f"Ошибка загрузки метаданных: {e}") + return metadata def _encode_image(self, image: Image.Image) -> np.ndarray: - """Кодирует изображение в эмбеддинг""" - tensor = self.image_encoder.preprocess_image(image) - if tensor.ndim == 3: - tensor = tensor.unsqueeze(0) # [C,H,W] -> [1,C,H,W] - tensor = tensor.to(self.device) - with torch.no_grad(): - emb = self.image_encoder(tensor).cpu().numpy() - return emb.astype("float32") - - def process_image(self, image_path: str, is_local: bool = False) -> Dict: + """Кодирует изображение в эмбеддинг с помощью GeoCLIP""" + try: + # Предобработка изображения + tensor = self.image_encoder.preprocess_image(image) + if tensor.ndim == 3: + tensor = tensor.unsqueeze(0) # [C,H,W] -> [1,C,H,W] + + # Перенос на устройство + tensor = tensor.to(self.device) + + # Получение эмбеддинга + with torch.no_grad(): + embedding = self.image_encoder(tensor) + embedding = embedding.cpu().numpy() + + logger.info(f"Получен эмбеддинг формы: {embedding.shape}") + return embedding.astype("float32") + + except Exception as e: + logger.error(f"Ошибка кодирования изображения: {e}") + raise + + def process_image(self, image_path: str) -> Dict: + """ + Обработка изображения: определение координат через GeoCLIP + FAISS + """ try: result = { "image_path": image_path, @@ -92,39 +226,61 @@ def process_image(self, image_path: str, is_local: bool = False) -> Dict: "ocr_result": None, } - # Загрузка изображения - if is_local: - image = Image.open(image_path).convert("RGB") - else: - image_data = s3_manager.download_bytes(image_path) - if image_data is None: - raise FileNotFoundError(f"Не удалось загрузить изображение: {image_path}") - image = Image.open(io.BytesIO(image_data)).convert("RGB") + # Загрузка изображения из S3 + logger.info(f"Загрузка изображения из S3: {image_path}") + image_data = s3_manager.download_bytes(image_path) + if image_data is None: + raise FileNotFoundError(f"Не удалось загрузить изображение из S3: {image_path}") + + # Открываем изображение + image = Image.open(io.BytesIO(image_data)) + if image.mode != "RGB": + image = image.convert("RGB") + logger.info(f"Изображение загружено: {image.size}") - # Кодирование - query_emb = self._encode_image(image) + # Кодирование в эмбеддинг + query_embedding = self._encode_image(image) - # Поиск ближайшего - results = self.faiss_indexer.search_similar(query_emb, k=1) - if not results: - raise ValueError("Не найдено ближайших изображений") + # Поиск похожих изображений + similar_results = self.faiss_indexer.search_similar(query_embedding, k=3) + logger.info(f"Найдено похожих изображений: {len(similar_results)}") - nearest_s3_key = results[0]["s3_key"] - coords = self.metadata.get(nearest_s3_key) - if not coords: - raise ValueError(f"Нет координат для {nearest_s3_key}") + if not similar_results: + # Если нет похожих изображений, используем заглушку для тестирования + logger.warning("Не найдено похожих изображений, используем тестовые координаты") + result["coordinates"] = {"lat": 55.7558, "lon": 37.6173} # Москва + else: + # Берем самое похожее изображение + best_match = similar_results[0] + s3_key = best_match["s3_key"] + similarity = best_match["similarity"] + + logger.info(f"Лучшее совпадение: {s3_key} (сходство: {similarity:.4f})") - result["coordinates"] = {"lat": coords["lat"], "lon": coords["lon"]} + # Получаем координаты + coords = self.metadata.get(s3_key) + if not coords: + logger.warning(f"Нет координат для изображения: {s3_key}, используем тестовые координаты") + result["coordinates"] = {"lat": 55.7558, "lon": 37.6173} + else: + result["coordinates"] = coords + logger.info(f"Определены координаты: {coords}") # Геокодирование - try: - address = geocode_coordinates(coords["lat"], coords["lon"]) - result["address"] = address - except Exception as e: - logger.warning(f"Ошибка геокодирования: {e}") + if result["coordinates"]: + try: + address = geocode_coordinates(result["coordinates"]["lat"], result["coordinates"]["lon"]) + result["address"] = address + logger.info(f"Определен адрес: {address}") + except Exception as e: + logger.warning(f"Ошибка геокодирования: {e}") + result["address"] = None - # Заглушка для зданий - result["buildings"] = [{"bbox": [0, 0, 100, 100], "confidence": 1.0, "area": 10000}] + # Детекция зданий (заглушка) + result["buildings"] = self._detect_buildings_placeholder() + + # OCR (заглушка) + result["ocr_result"] = self._perform_ocr_placeholder() return result @@ -132,19 +288,25 @@ def process_image(self, image_path: str, is_local: bool = False) -> Dict: logger.error(f"Ошибка обработки изображения {image_path}: {e}") raise + def _detect_buildings_placeholder(self) -> List[Dict]: + """Заглушка для детекции зданий""" + return [{ + "bbox": [0, 0, 100, 100], + "confidence": 1.0, + "area": 10000 + }] + + def _perform_ocr_placeholder(self) -> Dict: + """Заглушка для OCR""" + return { + "text": "ТЕКСТ НЕ РАСПОЗНАН", + "confidence": 0.0, + "roi_name": "full_image" + } + def create_cv_model() -> CVModel: """ Фабричная функция для создания экземпляра CVModel - - Returns - ------- - CVModel - Экземпляр CVModel - - Examples - -------- - >>> model = create_cv_model() - >>> result = model.process_image("path/to/image.jpg") """ - return CVModel() + return CVModel() \ No newline at end of file diff --git a/src/tasks/worker.py b/src/tasks/worker.py index 986a57f..aa86c86 100644 --- a/src/tasks/worker.py +++ b/src/tasks/worker.py @@ -188,7 +188,6 @@ def save_result_to_db(result: dict): db.execute( query, { - "photo_id": photo_id, "image_path": image_path, "task_id": task_id, "request_id": request_id,