From 02211eb0be01c676e88c42e48d4053eba9279135 Mon Sep 17 00:00:00 2001 From: Sebastian Brenner Date: Sat, 10 Jan 2026 22:26:16 +0100 Subject: [PATCH] init backend --- .dockerignore | 1 + .gitignore | 36 +++++++++++++++++++ README.md | 15 ++++++++ bun.lock | 26 ++++++++++++++ dockerfile | 31 +++++++++++++++++ package.json | 15 ++++++++ python_service/app.py | 81 +++++++++++++++++++++++++++++++++++++++++++ src/embeddings.ts | 21 +++++++++++ src/index.ts | 64 ++++++++++++++++++++++++++++++++++ src/types.ts | 10 ++++++ tsconfig.json | 29 ++++++++++++++++ 11 files changed, 329 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 README.md create mode 100644 bun.lock create mode 100644 dockerfile create mode 100644 package.json create mode 100644 python_service/app.py create mode 100644 src/embeddings.ts create mode 100644 src/index.ts create mode 100644 src/types.ts create mode 100644 tsconfig.json diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..ff38716 --- /dev/null +++ b/.dockerignore @@ -0,0 +1 @@ +python_service/venv \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..28ed146 --- /dev/null +++ b/.gitignore @@ -0,0 +1,36 @@ +# dependencies (bun install) +node_modules + +# output +out +dist +*.tgz + +# code coverage +coverage +*.lcov + +# logs +logs +_.log +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# caches +.eslintcache +.cache +*.tsbuildinfo + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store + +**/venv \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..e7e47ef --- /dev/null +++ b/README.md @@ -0,0 +1,15 @@ +# desktop + +To install dependencies: + +```bash +bun install +``` + +To run: + +```bash +bun run index.ts +``` + +This project was created using `bun init` in bun v1.3.5. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime. diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..9f52cc2 --- /dev/null +++ b/bun.lock @@ -0,0 +1,26 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "desktop", + "devDependencies": { + "@types/bun": "latest", + }, + "peerDependencies": { + "typescript": "^5", + }, + }, + }, + "packages": { + "@types/bun": ["@types/bun@1.3.5", "", { "dependencies": { "bun-types": "1.3.5" } }, "sha512-RnygCqNrd3srIPEWBd5LFeUYG7plCoH2Yw9WaZGyNmdTEei+gWaHqydbaIRkIkcbXwhBT94q78QljxN0Sk838w=="], + + "@types/node": ["@types/node@25.0.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA=="], + + "bun-types": ["bun-types@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + } +} diff --git a/dockerfile b/dockerfile new file mode 100644 index 0000000..1b74e2b --- /dev/null +++ b/dockerfile @@ -0,0 +1,31 @@ +# --- Base image with Node + Python --- +FROM ubuntu:22.04 + +# --- Install system dependencies --- +RUN apt-get update && apt-get install -y \ + curl python3 python3-pip git build-essential libgl1 libglvnd0 ffmpeg unzip \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +# --- Install Bun --- +RUN curl -fsSL https://bun.sh/install | bash +ENV PATH="/root/.bun/bin:${PATH}" + +# --- Install Python dependencies --- +RUN pip3 install --upgrade pip +RUN pip3 install torch --index-url https://download.pytorch.org/whl/cpu +RUN pip3 install --no-cache-dir fastapi uvicorn pillow open-clip-torch numpy faiss-cpu python-multipart + +# --- Copy project files --- +WORKDIR /app +COPY python_service ./python_service +COPY src ./src +COPY package.json . +COPY bun.lock . + +# --- Expose ports --- +EXPOSE 3000 + +# --- Start services --- +# Use & to run Python worker in background, Bun frontend as main process +CMD python3 python_service/app.py & bun run src/index.ts diff --git a/package.json b/package.json new file mode 100644 index 0000000..0984284 --- /dev/null +++ b/package.json @@ -0,0 +1,15 @@ +{ + "name": "desktop", + "module": "src/index.ts", + "type": "module", + "private": true, + "scripts": { + "dev": "bun run src/index.ts" + }, + "devDependencies": { + "@types/bun": "latest" + }, + "peerDependencies": { + "typescript": "^5" + } +} diff --git a/python_service/app.py b/python_service/app.py new file mode 100644 index 0000000..1c4f745 --- /dev/null +++ b/python_service/app.py @@ -0,0 +1,81 @@ +from fastapi import FastAPI, File, UploadFile +from fastapi.responses import JSONResponse +from fastapi.middleware.cors import CORSMiddleware +from PIL import Image +import torch, open_clip, numpy as np, faiss +import io + +app = FastAPI(title="Pokemon Card Image Service") + +# --- Allow CORS for Bun / Flutter --- +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=["*"], + allow_headers=["*"], +) + +import os + +BASE = os.path.dirname(os.path.abspath(__file__)) + +# --- Configuration --- +FAISS_INDEX_FILE = os.path.join(BASE, "card_index.faiss") +EMBEDDINGS_FILE = os.path.join(BASE, "embeddings.npy") +IDS_FILE = os.path.join(BASE, "ids.npy") +TOP_K = 5 + +# --- Load CLIP model --- +device = "cuda" if torch.cuda.is_available() else "cpu" +print("Loading model on device:", device) +model, _, preprocess = open_clip.create_model_and_transforms( + "ViT-L-14", pretrained="laion2b_s32b_b82k" +) +model = model.to(device).eval() + +# --- Load FAISS index --- +print("Loading FAISS index from disk...") +index = faiss.read_index(FAISS_INDEX_FILE) + +# --- Load IDs / metadata --- +ids = np.load(IDS_FILE) +metadata = {idx: {"id": ids[idx], "name": ids[idx]} for idx in range(len(ids))} + +print("Service ready! FAISS index contains", index.ntotal, "cards.") + +# --- Helper: encode image --- +def encode_image_bytes(image_bytes): + img = Image.open(io.BytesIO(image_bytes)).convert("RGB") + print("Image loaded successfully:", img.size) + with torch.no_grad(): + emb = model.encode_image(preprocess(img).unsqueeze(0).to(device)) + emb = emb.cpu().numpy() + faiss.normalize_L2(emb) + print("Embedding shape:", emb.shape) + return emb + +# --- API endpoint --- +@app.post("/query") +async def query_image(file: UploadFile = File(...)): + try: + image_bytes = await file.read() + print(f"Received {len(image_bytes)} bytes from {file.filename}") + + emb = encode_image_bytes(image_bytes) + + # --- FAISS search --- + D, I = index.search(emb, TOP_K) + print("FAISS distances:", D) + print("FAISS indices:", I) + + results = [metadata[int(i)] for i in I[0]] + return JSONResponse(content={"results": results}) + + except Exception as e: + print("ERROR during query:", e) + return JSONResponse(content={"error": str(e)}, status_code=500) + +# --- Run server --- +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host="0.0.0.0", port=5001) diff --git a/src/embeddings.ts b/src/embeddings.ts new file mode 100644 index 0000000..619142d --- /dev/null +++ b/src/embeddings.ts @@ -0,0 +1,21 @@ +import type { Card } from "./types"; + +// Placeholder cards array +let cards: Card[] = [ + { id: "swsh1-1", name: "Celebi V", set: "Swsh1", number: "1", imageUrl: "https://assets.tcgdex.net/de/swsh/swsh1/1/high.png" }, + { id: "swsh12-001", name: "Bluzuk", set: "Swsh12", number: "001", imageUrl: "https://assets.tcgdex.net/de/swsh/swsh12/001/high.png" }, +]; + +export function loadCards() { + // Placeholder: you can later load embeddings.npy + FAISS + console.log("Cards module loaded (currently empty)"); +} + +export function queryCardById(id: string): Card | null { + return cards.find(c => c.id === id) || null; +} + +// Example placeholder: return top N matches +export function queryCardByEmbedding(/* embedding */): Card[] { + return cards.slice(0, 5); // dummy top 5 +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..90b1277 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,64 @@ +import { serve, spawn } from 'bun'; +import { queryCardByEmbedding, queryCardById } from './embeddings'; +import type { Card } from './types'; + +const PYTHON_SERVICE = "http://localhost:5001/query"; + +const server = serve({ + routes: { + "/api/cards/query-image": { + async POST(req) { + try { + const formData = await req.formData(); + const file = formData.get("file") as File; + if (!file) return new Response(JSON.stringify({ error: "No file uploaded" }), { status: 400 }); + + // Forward directly to Python microservice + const body = new FormData(); + body.append("file", file); + + const resp = await fetch(PYTHON_SERVICE, { method: "POST", body }); + const json = await resp.json(); + + return new Response(JSON.stringify(json), { headers: { "Content-Type": "application/json" } }); + } catch (err) { + console.error(err); + return new Response(JSON.stringify({ error: "Failed to query image" }), { status: 500 }); + } + }, + }, + "/api/cards/:id": async (req) => { + const { id } = req.params; + const card = queryCardById(id); + if (!card) return new Response(JSON.stringify({ error: "Card not found" }), { status: 404, headers: { "Content-Type": "application/json" } }); + return new Response(JSON.stringify(card), { headers: { "Content-Type": "application/json" } }); + }, + "/*": async () => { + return new Response("

Pokemon Card Backend

", { headers: { "Content-Type": "text/html" } }); + }, + + /* "/api/cards/query": { + async POST(req) { + try { + const { embedding } = await req.json() as { embedding: number[] }; + if (!embedding) return new Response(JSON.stringify({ error: "Missing embedding" }), { status: 400, headers: { "Content-Type": "application/json" } }); + const results: Card[] = queryCardByEmbedding(embedding); + return new Response(JSON.stringify(results), { headers: { "Content-Type": "application/json" } }); + } catch (err) { + console.error("Error querying card:", err); + return new Response(JSON.stringify({ error: "Failed to query card" }), { status: 500, headers: { "Content-Type": "application/json" } }); + } + }, + }, */ + }, + + development: process.env.NODE_ENV !== 'production' && { + // Enable browser hot reloading in development + hmr: true, + + // Echo console logs from the browser to the server + console: true, + }, +}); + +console.log(`🚀 Server running at ${server.url}`); diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..90a76fc --- /dev/null +++ b/src/types.ts @@ -0,0 +1,10 @@ +export interface Card { + id: string; + name: string; + set: string; + number: string; + rarity?: string; + variant?: string; + foil?: boolean; + imageUrl: string; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..bfa0fea --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + // Environment setup & latest features + "lib": ["ESNext"], + "target": "ESNext", + "module": "Preserve", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + } +}