diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index c6d88ba..3636014 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -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'
diff --git a/client/src/features/dashboard/Help&Support/hooks.ts b/client/src/features/dashboard/Help&Support/hooks.ts
index adb1fea..087b232 100644
--- a/client/src/features/dashboard/Help&Support/hooks.ts
+++ b/client/src/features/dashboard/Help&Support/hooks.ts
@@ -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) => {
diff --git a/client/src/features/dashboard/layout.tsx b/client/src/features/dashboard/layout.tsx
index 7e44e89..3c09b78 100644
--- a/client/src/features/dashboard/layout.tsx
+++ b/client/src/features/dashboard/layout.tsx
@@ -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 + "/"));
@@ -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 (
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,
@@ -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
\ No newline at end of file
+ return {"status": "ok", "message": "Scan model service is running."}
\ No newline at end of file
diff --git a/server/package-lock.json b/server/package-lock.json
index e0455d1..b2a6dfd 100644
--- a/server/package-lock.json
+++ b/server/package-lock.json
@@ -28,7 +28,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",
@@ -7059,18 +7058,6 @@
"node": ">=0.8"
}
},
- "node_modules/cloudinary": {
- "version": "2.9.0",
- "resolved": "https://registry.npmjs.org/cloudinary/-/cloudinary-2.9.0.tgz",
- "integrity": "sha512-F3iKMOy4y0zy0bi5JBp94SC7HY7i/ImfTPSUV07iJmRzH1Iz8WavFfOlJTR1zvYM/xKGoiGZ3my/zy64In0IQQ==",
- "license": "MIT",
- "dependencies": {
- "lodash": "^4.17.21"
- },
- "engines": {
- "node": ">=9"
- }
- },
"node_modules/cluster-key-slot": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
diff --git a/server/package.json b/server/package.json
index 63af320..ec6237b 100644
--- a/server/package.json
+++ b/server/package.json
@@ -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",
diff --git a/server/src/Organization/organization.service.ts b/server/src/Organization/organization.service.ts
index 519200d..443a5f4 100644
--- a/server/src/Organization/organization.service.ts
+++ b/server/src/Organization/organization.service.ts
@@ -427,6 +427,4 @@ export class OrganizationService {
return { success: true };
}
-
- //---------------- AUDIT LOGS ----------------//
}
\ No newline at end of file