landvex: Datafabrik + Vision + Infrastruktur för 50GB skalning

Datafabrik:
- Skördare: crawler, källvitlista, upphandlingsskördare
- Extraktor: LLM-baserad schemastyrd extraktion
- Upplösare: Entitetsupplösning och deduplicering
- Köer: Schemalagd / kunddriven / fält
- Agentorkestrering: 20+ parallella agenter

Vision:
- Identify-modell: ResNet50 + kontrastivt lärande
- Träningspipeline: NT-Xent loss
- Vektordatabas: FAISS för snabb sökning
- OCR-pipeline: Typskyltsläsning

Infrastruktur:
- Docker Compose production
- Terraform för AWS ECS
- Prometheus + Grafana monitorering
- Neo4j + FAISS + MinIO + Redis
This commit is contained in:
Bernt
2026-07-05 06:25:51 +00:00
parent 7d40cf95bf
commit f4f853d94b
20 changed files with 2631 additions and 63 deletions
@@ -0,0 +1,179 @@
#!/usr/bin/env python3
"""
Landvex Vision — OCR-pipeline för typskyltar
Extraherar tillverkare, modell, serienummer från foton.
"""
import json
from pathlib import Path
from typing import List, Dict, Optional, Tuple
from dataclasses import dataclass
import pytesseract
from PIL import Image
import cv2
import numpy as np
@dataclass
class OCRResultat:
text: str
konfidens: float
bbox: Tuple[int, int, int, int] # x, y, w, h
falt: Dict[str, str] # Extraherade fält
class LandvexOCR:
"""OCR-pipeline optimerad för infrastrukturtypskyltar."""
def __init__(self, tesseract_cmd: Optional[str] = None):
if tesseract_cmd:
pytesseract.pytesseract.tesseract_cmd = tesseract_cmd
# Fältmönster per objektklass
self.falt_monster = {
"LVX-ELN-0101": { # Kabelskåp
"tillverkare": r"(ABB|Schneider|Siemens|GE)\s*[\w-]*",
"modell": r"(CDC|Prisma|Okken|IM\s*\w+)",
"ar": r"20\d{2}",
},
"LVX-TRP-0102": { # Vägbelysningsarmatur
"tillverkare": r"(Philips|Thorn|Schreder|Acuity|Cree)",
"modell": r"(SL-\w+|ER\w+|Vista\w+)",
"effekt": r"(\d+)\s*W",
},
"LVX-VAT-0101": { # Brunnsbetäckning
"tillverkare": r"(ULMA|ACO|Wrede|GDK)",
"klass": r"(A15|B125|C250|D400|E600|F900)",
"material": r"(gjutjärn|stål|komposit|betong)",
},
}
def forbehandla_bild(self, bild: np.ndarray) -> np.ndarray:
"""Förbättra bildkvalitet för OCR."""
# Konvertera till gråskala
if len(bild.shape) == 3:
gray = cv2.cvtColor(bild, cv2.COLOR_RGB2GRAY)
else:
gray = bild
# Brusreducering
denoised = cv2.fastNlMeansDenoising(gray)
# Kontrastförbättring (CLAHE)
clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))
enhanced = clahe.apply(denoised)
# Skärpa
kernel = np.array([[-1, -1, -1], [-1, 9, -1], [-1, -1, -1]])
sharpened = cv2.filter2D(enhanced, -1, kernel)
return sharpened
def hitta_text_regioner(self, bild: np.ndarray) -> List[Tuple[int, int, int, int]]:
"""Hitta regioner som troligen innehåller text."""
# MSER (Maximally Stable Extremal Regions)
mser = cv2.MSER_create()
regions, _ = mser.detectRegions(bild)
# Filtrera små regioner
h, w = bild.shape
min_area = (h * w) * 0.001 # Minst 0.1% av bilden
bboxes = []
for region in regions:
x, y, w, h = cv2.boundingRect(region)
if w * h > min_area and w > h * 2: # Text är oftast bredare än hög
bboxes.append((x, y, w, h))
return bboxes
def kora_ocr(self, bild: Image.Image, lvx_id: Optional[str] = None) -> List[OCRResultat]:
"""Kör OCR på bild och extrahera fält."""
# Konvertera till numpy
img_array = np.array(bild)
# Förbehandla
processed = self.forbehandla_bild(img_array)
# Hitta textregioner
regioner = self.hitta_text_regioner(processed)
resultat = []
for x, y, w, h in regioner[:5]: # Max 5 regioner
# Beskär region
roi = processed[y:y+h, x:x+w]
# Kör Tesseract
text = pytesseract.image_to_string(roi, lang='eng+swe')
conf_data = pytesseract.image_to_data(roi, output_type=pytesseract.Output.DICT)
# Beräkna medelkonfidens
konfidenser = [c for c in conf_data['conf'] if c > 0]
medel_konf = np.mean(konfidenser) if konfidenser else 0
# Extrahera fält om vi vet objektklassen
falt = {}
if lvx_id and lvx_id in self.falt_monster:
import re
for falt_namn, monster in self.falt_monster[lvx_id].items():
match = re.search(monster, text, re.IGNORECASE)
if match:
falt[falt_namn] = match.group(1)
resultat.append(OCRResultat(
text=text.strip(),
konfidens=medel_konf / 100.0, # Normalisera till 0-1
bbox=(x, y, w, h),
falt=falt
))
# Sortera efter konfidens
resultat.sort(key=lambda x: x.konfidens, reverse=True)
return resultat
def extrahera_falt(self, text: str, lvx_id: str) -> Dict[str, str]:
"""Extrahera strukturerade fält från OCR-text."""
import re
falt = {}
monster = self.falt_monster.get(lvx_id, {})
for falt_namn, pattern in monster.items():
matches = re.findall(pattern, text, re.IGNORECASE)
if matches:
falt[falt_namn] = matches[0]
return falt
def main():
"""Demo: OCR på syntetisk bild."""
print("🔤 Landvex OCR Pipeline")
ocr = LandvexOCR()
# Skapa syntetisk testbild med text
from PIL import ImageDraw, ImageFont
img = Image.new('RGB', (400, 200), color='white')
draw = ImageDraw.Draw(img)
try:
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 24)
except:
font = ImageFont.load_default()
draw.text((20, 20), "ABB Kabeldon", fill='black', font=font)
draw.text((20, 60), "CDC-LT 400", fill='black', font=font)
draw.text((20, 100), "2023", fill='black', font=font)
# Kör OCR
resultat = ocr.kora_ocr(img, lvx_id="LVX-ELN-0101")
print("\n🎯 OCR-resultat:")
for i, r in enumerate(resultat[:3]):
print(f" Region {i+1}:")
print(f" Text: {r.text[:100]}")
print(f" Konfidens: {r.konfidens:.2f}")
print(f" Fält: {r.falt}")
print("\n✅ OCR klar!")
if __name__ == "__main__":
main()
@@ -0,0 +1,140 @@
#!/usr/bin/env python3
"""
Landvex Vision — Vektordatabas
Lagrar och söker bild- och textembeddings.
"""
import json
import numpy as np
from pathlib import Path
from typing import List, Dict, Tuple, Optional
from dataclasses import dataclass
import faiss # Facebook AI Similarity Search
@dataclass
class VektorsokResultat:
lvx_id: str
distans: float
metadata: dict
class LandvexVektordatabas:
"""Vektordatabas för bild- och textembeddings."""
def __init__(self, dimension: int = 2048, index_type: str = "FlatIP"):
self.dimension = dimension
self.index_type = index_type
# FAISS index för snabb sökning
if index_type == "FlatIP":
self.index = faiss.IndexFlatIP(dimension) # Inner product (cosine om normaliserad)
elif index_type == "IVF"::
nlist = 100 # Antal kluster
quantizer = faiss.IndexFlatIP(dimension)
self.index = faiss.IndexIVFFlat(quantizer, dimension, nlist)
else:
raise ValueError(f"Okänd index-typ: {index_type}")
# Mappning index_id → lvx_id
self.id_map: List[str] = []
self.metadata: Dict[str, dict] = {}
def lagg_till(self, lvx_id: str, embedding: np.ndarray, metadata: dict):
"""Lägg till embedding i databasen."""
# Normalisera för cosine similarity
embedding = embedding / np.linalg.norm(embedding)
embedding = embedding.reshape(1, -1).astype('float32')
self.index.add(embedding)
self.id_map.append(lvx_id)
self.metadata[lvx_id] = metadata
def sok(self, query: np.ndarray, top_k: int = 5) -> List[VektorsokResultat]:
"""Sök närmaste grannar."""
query = query / np.linalg.norm(query)
query = query.reshape(1, -1).astype('float32')
distanser, indices = self.index.search(query, top_k)
resultat = []
for dist, idx in zip(distanser[0], indices[0]):
if idx < 0 or idx >= len(self.id_map):
continue
lvx_id = self.id_map[idx]
resultat.append(VektorsokResultat(
lvx_id=lvx_id,
distans=float(dist),
metadata=self.metadata.get(lvx_id, {})
))
return resultat
def spara(self, path: Path):
"""Spara index och metadata."""
path.parent.mkdir(parents=True, exist_ok=True)
# Spara FAISS-index
faiss.write_index(self.index, str(path / "index.faiss"))
# Spara metadata
with open(path / "metadata.json", 'w') as f:
json.dump({
"id_map": self.id_map,
"metadata": self.metadata,
"dimension": self.dimension,
"index_type": self.index_type,
}, f, indent=2)
print(f" 💾 Vektordatabas sparad: {path}")
def ladda(self, path: Path):
"""Ladda index och metadata."""
self.index = faiss.read_index(str(path / "index.faiss"))
with open(path / "metadata.json", 'r') as f:
data = json.load(f)
self.id_map = data["id_map"]
self.metadata = data["metadata"]
self.dimension = data["dimension"]
self.index_type = data["index_type"]
print(f" 📂 Vektordatabas laddad: {len(self.id_map)} vektorer")
def main():
"""Demo: vektordatabas."""
print("🔍 Landvex Vektordatabas")
db = LandvexVektordatabas(dimension=128) # Låg dimension för demo
# Lägg till exempel-embeddings
np.random.seed(42)
for i in range(100):
emb = np.random.randn(128)
db.lagg_till(
lvx_id=f"LVX-TRP-{1000+i:04d}",
embedding=emb,
metadata={
"namn_sv": f"Testobjekt {i}",
"verifieringsniva": "kallbelagd",
}
)
# Sök
query = np.random.randn(128)
resultat = db.sok(query, top_k=5)
print("\n🎯 Sökresultat:")
for r in resultat:
print(f" {r.lvx_id}: distans={r.distans:.3f}, {r.metadata['namn_sv']}")
# Spara och ladda
db.spara(Path("/tmp/landvex-vectordb"))
db2 = LandvexVektordatabas()
db2.ladda(Path("/tmp/landvex-vectordb"))
print(f"\n✅ Databas: {len(db2.id_map)} vektorer")
if __name__ == "__main__":
main()