Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ on:
workflow_dispatch:
inputs:
force_build:
description: 'Force rebuild all services (backend/rag/model)'
description: 'Force rebuild all services'
required: false
default: 'false'

Expand Down
3 changes: 1 addition & 2 deletions client/src/features/dashboard/Help&Support/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,7 @@ export interface SupportContactPayload {
message: string
}

// Send a support request to the TumorLens team (contact@tumorlens.health).
// The sender's name and email are resolved server-side from the logged-in user.
// Submit Message to Support
export const useSubmitSupport = () =>
useMutation({
mutationFn: async (data: SupportContactPayload) => {
Expand Down
4 changes: 3 additions & 1 deletion client/src/features/dashboard/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -267,7 +267,6 @@ export const DashboardLayout = ({ children }: { children?: React.ReactNode } = {
const currentPath = useRouterState({ select: (s) => s.location.pathname });
const allNavItems = NAV.flatMap((s) => s.items);

// Exact match first, then prefix match for sub-routes (e.g. /results/PAT-00042)
const activeNavItem =
allNavItems.find((i) => i.id === currentPath) ??
allNavItems.find((i) => i.id !== "/" && currentPath.startsWith(i.id + "/"));
Expand All @@ -292,6 +291,9 @@ export const DashboardLayout = ({ children }: { children?: React.ReactNode } = {
return () => window.removeEventListener("keydown", handler);
}, []);

// Org Member
const isOrgMember = user?.org?.role === "member";

return (
<div
className="flex h-screen overflow-hidden"
Expand Down
103 changes: 56 additions & 47 deletions scan_model/app.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import io
from fastapi import FastAPI, File, UploadFile, HTTPException
from fastapi import FastAPI, File, UploadFile
from torchvision import transforms
import torch
from PIL import Image
Expand All @@ -11,66 +11,80 @@
model = timm.create_model('efficientnet_b0', pretrained=False, num_classes=4)
model.load_state_dict(torch.load("model.pth", map_location="cpu"))
model.eval()

CLASSES = ['glioma', 'meningioma', 'notumor', 'pituitary']
transform = transforms.Compose([
transforms.Resize((224, 224)),
transforms.ToTensor(),
transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]),
])

# Prediction Endpoint

def heuristic_mri_check(image: Image.Image) -> tuple[bool, str]:
"""Reject obvious non-MRI input using cheap image-statistics checks."""
arr = np.asarray(image.resize((224, 224)), dtype=np.float32)
r, g, b = arr[..., 0], arr[..., 1], arr[..., 2]

# 1. Grayscale: MRI scans have R≈G≈B
chan_diff = (np.abs(r - g).mean() + np.abs(r - b).mean() + np.abs(g - b).mean()) / 3
if chan_diff > 12.0:
return False, "Image is colored; MRI scans are grayscale."

gray = arr.mean(axis=2)

# 2. Dark background: borders should be mostly black
border = np.concatenate([
gray[:20, :].ravel(), gray[-20:, :].ravel(),
gray[:, :20].ravel(), gray[:, -20:].ravel(),
])
if (border < 30).mean() < 0.55:
return False, "Borders are not dark; no MRI background detected."

# 3. Central mass: center brighter than edges
h, w = gray.shape
center = gray[h // 4:3 * h // 4, w // 4:3 * w // 4].mean()
if center < 35 or center <= border.mean() + 10:
return False, "No central tissue mass detected."

# 4. Bimodal histogram: dark background peak + tissue peak
hist, _ = np.histogram(gray, bins=32, range=(0, 255))
if hist[:6].sum() == 0 or hist[6:].sum() == 0 or hist[:6].sum() / gray.size < 0.20:
return False, "Intensity distribution inconsistent with MRI."

return True, "Passed heuristic MRI checks."


@app.post("/predict")
async def predict(file: UploadFile = File(...)):

# Layer 1 — file type
if file.content_type not in ['image/jpeg', 'image/png']:
return {
"success": False,
"message": "Unsupported file type — only JPEG and PNG are allowed"
}
# Layer 1 — declared type (cheap filter, not trusted)
if file.content_type not in ('image/jpeg', 'image/png'):
return {"success": False, "message": "Unsupported file type — only JPEG and PNG are allowed"}

contents = await file.read()

# Layer 2 — empty or corrupted
if len(contents) == 0:
return {
"success": False,
"message": "Empty file — no data to process"
}

# Layer 2 — empty / corrupted
if not contents:
return {"success": False, "message": "Empty file — no data to process"}
try:
Image.open(io.BytesIO(contents)).verify() # validate integrity
image = Image.open(io.BytesIO(contents)).convert("RGB")
except Exception:
return {
"success": False,
"message": "Corrupted image — unable to open"
}

# Check if image is grayscale (MRI scans are grayscale)
image_array = np.array(image)
r, g, b = image_array[:,:,0], image_array[:,:,1], image_array[:,:,2]
is_grayscale = np.mean(np.abs(r.astype(int) - g.astype(int))) < 10 and \
np.mean(np.abs(g.astype(int) - b.astype(int))) < 10

if not is_grayscale:
return {
"success": False,
"message": "Invalid image — expected a grayscale MRI scan"
}
return {"success": False, "message": "Corrupted image — unable to open"}

tensor = transform(image).unsqueeze(0)
# Layer 3 — classical heuristic pre-check
ok, reason = heuristic_mri_check(image)
if not ok:
return {"success": False, "message": f"Invalid MRI - {reason}"}

# Inference
tensor = transform(image).unsqueeze(0)
with torch.no_grad():
outputs = model(tensor)
probs = torch.softmax(outputs, dim=1)[0]
probs = torch.softmax(model(tensor), dim=1)[0]
confidence, predicted = torch.max(probs, 0)

# Layer 3 — confidence threshold
# Layer 4 — confidence threshold
if confidence.item() < 0.70:
return {
"success": False,
"message": "Low confidence — not a valid MRI scan"
}
return {"success": False, "message": "Low confidence — prediction unreliable"}

return {
"success": True,
Expand All @@ -79,12 +93,7 @@ async def predict(file: UploadFile = File(...)):
"all_scores": {c: round(probs[i].item(), 4) for i, c in enumerate(CLASSES)},
}

# Health Check Endpoint

@app.get("/")
def health():
return {
"status": "ok",
"message": "Scan model service is running."
}

# uv run uvicorn app:app --reload
return {"status": "ok", "message": "Scan model service is running."}
13 changes: 0 additions & 13 deletions server/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,6 @@
"bullmq": "^5.76.6",
"class-transformer": "^0.5.1",
"class-validator": "^0.15.1",
"cloudinary": "^2.9.0",
"cookie-parser": "^1.4.7",
"helmet": "^8.1.0",
"otplib": "^13.4.0",
Expand Down
2 changes: 0 additions & 2 deletions server/src/Organization/organization.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -427,6 +427,4 @@ export class OrganizationService {

return { success: true };
}

//---------------- AUDIT LOGS ----------------//
}
Loading