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:
@@ -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()
|
||||
Reference in New Issue
Block a user