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