diff --git a/.gitignore b/.gitignore index 649f156..0f29b25 100644 --- a/.gitignore +++ b/.gitignore @@ -53,6 +53,10 @@ checkpoints/ *.tflite *.pb +# Exception: the models the mobile apps ship are part of the app, not local +# artifacts — without them the apps cannot be built or run. +!frontend/android/app/src/main/assets/second_look.tflite + # Notebook checkpoints .ipynb_checkpoints/ diff --git a/frontend/android/.gitignore b/frontend/android/.gitignore new file mode 100644 index 0000000..1c1fea1 --- /dev/null +++ b/frontend/android/.gitignore @@ -0,0 +1,28 @@ +# --------------------------------------------------------------------------- +# SecondLook — Android / Gradle .gitignore +# NOTE: the Gradle wrapper (gradlew, gradlew.bat, gradle/wrapper/*) IS +# committed, so a clone builds with the right Gradle version, and so is the +# bundled second_look.tflite model the app ships. +# --------------------------------------------------------------------------- + +# Build output +build/ +/captures/ +.cxx/ +.externalNativeBuild/ + +# Gradle / IDE local state +.gradle/ +.kotlin/ +.idea/ +*.iml + +# Machine-specific: SDK path, and any chaquopyBuildPython override +local.properties + +# Python bytecode from running the vendored pipeline outside Android +__pycache__/ +*.py[cod] + +# macOS +.DS_Store diff --git a/frontend/android/README.md b/frontend/android/README.md new file mode 100644 index 0000000..10ec810 --- /dev/null +++ b/frontend/android/README.md @@ -0,0 +1,118 @@ +# SecondLook (Android) + +An Android app that runs on-device triage of mammogram images. A user selects or +captures a mammogram, the image is preprocessed and passed through a bundled +TensorFlow Lite model, and the app returns a probability and a coarse tier +(Low / Moderate / Elevated). + +This is the Android counterpart of the iOS app in `frontend/`, with the same +four-screen flow, the same palette (Apple's system colors), and the same model. + +## ⚠️ Not a medical device + +SecondLook is a research and educational prototype. It is **not** a diagnostic +tool, is **not** FDA/CE cleared, and must not be used to make clinical or +personal health decisions. The tier cut-points are provisional and uncalibrated. +The app shows a disclaimer gate on launch for this reason. + +## How it works + +1. **Capture / select** an image — `ui/SecondLookFlow.kt` (Compose): disclaimer + gate → capture → scanning → results. +2. **Preprocess** it by running the research pipeline's **actual Python code** + on-device through [Chaquopy](https://chaquo.com/chaquopy/): grayscale → + CLAHE → breast mask → pectoral-muscle removal → orientation normalization → + resize to 224×224 → float32 in `[0, 1]`. +3. **Classify** with `second_look.tflite` via `MammogramClassifier.kt`, + producing `P(worth second look)` and a tier. + +Nothing is uploaded and nothing is written to shared storage: camera captures go +to the app's cache directory and the preprocessing scratch file is deleted after +each scan. + +### Why Python instead of a port + +The iOS app hand-ports the pipeline to Objective-C++/OpenCV, which means two +implementations that can silently drift apart — and a drift here feeds the model +inputs it was never trained on. Android can embed CPython, so this app runs the +training-time code verbatim: + +``` +app/src/main/python/ + config/constants.py ┐ + data_pipeline/_imaging_utils.py ├ copied verbatim from the research repo + data_pipeline/preprocessor.py │ + data_pipeline/label_mapper.py ┘ + data_pipeline/__init__.py trimmed: the repo's version imports the + whole training stack (pandas, GCS, TF) + second_look_bridge.py the only Android-specific Python +``` + +Refresh the copied files after changing them upstream: + +```bash +./gradlew :app:syncPythonPipeline # defaults to the repo root (../..) +./gradlew :app:syncPythonPipeline -PsecondLookRepo=/path/to/secondlook +``` + +## Requirements + +- Android Studio (AGP 9.2.x — pinned because Chaquopy 17 supports AGP ≤ 9.2) +- JDK 21 (Android Studio's bundled JBR is fine) +- **Python 3.10 on the build machine**, for Chaquopy's `buildPython`: + + ```bash + brew install python@3.10 # macOS + ``` + + 3.10 specifically: it is the only version with OpenCV wheels in Chaquopy's + Android package repository, and the pipeline is cv2-based. Chaquopy finds + `python3.10` on `PATH`; if Android Studio does not inherit your shell `PATH`, + set an explicit path instead: + + ```properties + # local.properties or gradle.properties + chaquopyBuildPython=/opt/homebrew/bin/python3.10 + ``` + +Minimum device: API 24, `arm64-v8a` or `x86_64` (the ABIs Chaquopy's NumPy and +OpenCV wheels are built for — see `abiFilters`). + +## Build & run + +```bash +./gradlew :app:installDebug # build + install on a running device +./gradlew :app:connectedAndroidTest # pipeline + inference tests, on-device +``` + +The first build downloads the CPython runtime plus NumPy and OpenCV wheels, and +the first launch unpacks them, so both are slower than usual. + +Note: the Gradle configuration cache is disabled in `gradle.properties` — +Chaquopy shells out to `buildPython` during configuration, which the cache +forbids. + +## The model + +`app/src/main/assets/second_look.tflite` (~4.4 MB) is the trained classifier, +bundled into the APK and memory-mapped at runtime (hence `noCompress += "tflite"`). +It is trained separately in the Python pipeline. If you retrain, replace this +file and keep the 224×224×1 input contract — `MammogramClassifier` verifies the +preprocessed tensor against the model's declared input size and fails loudly if +they disagree. + +## Project layout + +``` +app/src/main/java/com/hexogen/secondlook/ + SecondLookApplication.kt Starts the CPython runtime once per process + MainActivity.kt Entry point + ui/SecondLookFlow.kt The four screens (disclaimer → capture → results) + ui/theme/ Colors, typography, tier palette + MammogramPreprocessor.kt Kotlin ↔ Python bridge + MammogramClassifier.kt TFLite interpreter wrapper + tiering + ImageLoading.kt EXIF orientation, preview decoding, capture URIs +app/src/main/python/ The preprocessing pipeline (see above) +app/src/main/assets/ second_look.tflite +app/src/androidTest/ On-device pipeline + inference tests +``` diff --git a/frontend/android/app/.gitignore b/frontend/android/app/.gitignore new file mode 100644 index 0000000..edbb865 --- /dev/null +++ b/frontend/android/app/.gitignore @@ -0,0 +1,4 @@ +/build +# Local bytecode from running the vendored pipeline outside Android +__pycache__/ +*.py[cod] diff --git a/frontend/android/app/build.gradle.kts b/frontend/android/app/build.gradle.kts new file mode 100644 index 0000000..b6268d7 --- /dev/null +++ b/frontend/android/app/build.gradle.kts @@ -0,0 +1,120 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.chaquopy) +} + +android { + namespace = "com.hexogen.secondlook" + compileSdk { + version = release(36) { + minorApiLevel = 1 + } + } + + defaultConfig { + applicationId = "com.hexogen.secondlook" + minSdk = 24 + targetSdk = 36 + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + + // Chaquopy packs a CPython runtime plus native NumPy/OpenCV per ABI. + // Restrict to the two ABIs that matter (real devices + emulator) — + // each extra ABI adds tens of megabytes to the APK. + ndk { + abiFilters += listOf("arm64-v8a", "x86_64") + } + } + + buildTypes { + release { + optimization { + enable = false + } + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + buildFeatures { + compose = true + } + androidResources { + // TFLite models are memory-mapped from the APK, so they must be stored + // uncompressed. + noCompress += "tflite" + } +} + +chaquopy { + defaultConfig { + // 3.10 is the only Python version with OpenCV wheels in Chaquopy's + // package repository, and the preprocessing pipeline is cv2-based. + // buildPython must be a local Python 3.10 — see README. + version = "3.10" + + // Chaquopy looks for `python3.10` on PATH. Android Studio launched from + // the Dock does not always inherit a shell PATH, so allow an override: + // ./gradlew -PchaquopyBuildPython=/opt/homebrew/bin/python3.10 ... + // or set chaquopyBuildPython in gradle.properties / local.properties. + providers.gradleProperty("chaquopyBuildPython").orNull?.let { buildPython(it) } + + pip { + // Versions are the newest cp310 Android wheels Chaquopy publishes. + install("numpy==1.26.2") + install("opencv-python==4.5.1.48") + } + } +} + +dependencies { + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.ui.graphics) + implementation(libs.androidx.compose.ui.tooling.preview) + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.exifinterface) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.tensorflow.lite) + testImplementation(libs.junit) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.compose.ui.test.junit4) + androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(libs.androidx.junit) + debugImplementation(libs.androidx.compose.ui.test.manifest) + debugImplementation(libs.androidx.compose.ui.tooling) +} + +// Refresh the vendored copies of the Python pipeline from the Second Look +// research repo. The copies under src/main/python are the ones that ship; this +// task just keeps them from silently drifting. The default resolves to the repo +// root two levels up (frontend/android/ -> repo root); override with +// -PsecondLookRepo=... for a checkout somewhere else. +val secondLookRepo = providers.gradleProperty("secondLookRepo") + .orElse(rootProject.layout.projectDirectory.dir("../..").asFile.path) + +tasks.register("syncPythonPipeline") { + group = "second look" + description = "Copy preprocessing sources from the Second Look Python repo into src/main/python." + + val repoDir = file(secondLookRepo.get()) + onlyIf { + repoDir.isDirectory.also { + if (!it) logger.warn("Second Look repo not found at $repoDir — nothing to sync.") + } + } + + from(repoDir) { + include("config/constants.py") + include("data_pipeline/_imaging_utils.py") + include("data_pipeline/preprocessor.py") + include("data_pipeline/label_mapper.py") + } + into(layout.projectDirectory.dir("src/main/python")) +} diff --git a/frontend/android/app/src/androidTest/java/com/hexogen/secondlook/MammogramPipelineTest.kt b/frontend/android/app/src/androidTest/java/com/hexogen/secondlook/MammogramPipelineTest.kt new file mode 100644 index 0000000..3370103 --- /dev/null +++ b/frontend/android/app/src/androidTest/java/com/hexogen/secondlook/MammogramPipelineTest.kt @@ -0,0 +1,91 @@ +package com.hexogen.secondlook + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.RectF +import android.net.Uri +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import java.io.File + +/** + * End-to-end checks for the on-device path: Python preprocessing through + * TensorFlow Lite inference. Instrumented rather than local because both the + * CPython runtime and the model live in the APK. + */ +@RunWith(AndroidJUnit4::class) +class MammogramPipelineTest { + + private val context: Context = InstrumentationRegistry.getInstrumentation().targetContext + + @Test + fun inputSizeComesFromTheSharedPythonConfig() { + assertEquals(224, MammogramPreprocessor.inputSize) + } + + @Test + fun preprocessingProducesTheModelInputTensor() { + val size = MammogramPreprocessor.inputSize + val tensor = MammogramPreprocessor.preprocess(context, syntheticMammogram()) + + assertEquals("float32 [1, $size, $size, 1]", size * size * 4, tensor.capacity()) + + val floats = tensor.asFloatBuffer() + var sum = 0f + for (i in 0 until floats.limit()) { + val value = floats.get(i) + assertTrue("value $value at $i is outside [0, 1]", value in 0f..1f) + sum += value + } + // The pipeline masks background to zero but must not zero everything: + // an all-black tensor means masking ate the breast region. + assertTrue("preprocessed tensor is entirely zero", sum > 0f) + } + + @Test + fun classificationReturnsAProbabilityAndMatchingTier() { + val result = MammogramClassifier(context).use { it.classify(syntheticMammogram()) } + + assertTrue("probability ${result.probability} outside [0, 1]", result.probability in 0f..1f) + assertEquals(MammogramClassifier.tierFor(result.probability), result.tier) + } + + @Test + fun tierCutPointsMatchTheSharedThresholds() { + // Mirrors data_pipeline.label_mapper.TIER_THRESHOLDS (provisional). + assertEquals("Low", MammogramClassifier.tierFor(0f)) + assertEquals("Low", MammogramClassifier.tierFor(0.329f)) + assertEquals("Moderate", MammogramClassifier.tierFor(0.33f)) + assertEquals("Moderate", MammogramClassifier.tierFor(0.659f)) + assertEquals("Elevated", MammogramClassifier.tierFor(0.66f)) + assertEquals("Elevated", MammogramClassifier.tierFor(1f)) + } + + /** + * A stand-in mammogram: a bright half-ellipse of "tissue" against a dark + * background, plus a wedge in the top corner for the pectoral heuristic to + * find. Enough structure for every stage of the pipeline to do real work. + */ + private fun syntheticMammogram(): Uri { + val bitmap = Bitmap.createBitmap(400, 600, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bitmap) + canvas.drawColor(Color.rgb(8, 8, 8)) + + val tissue = Paint().apply { color = Color.rgb(190, 190, 190); isAntiAlias = true } + canvas.drawOval(RectF(-160f, 60f, 280f, 540f), tissue) + + val pectoral = Paint().apply { color = Color.rgb(245, 245, 245) } + canvas.drawRect(RectF(0f, 0f, 150f, 40f), pectoral) + + val file = File.createTempFile("test_mammogram", ".png", context.cacheDir) + file.outputStream().use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) } + return Uri.fromFile(file) + } +} diff --git a/frontend/android/app/src/main/AndroidManifest.xml b/frontend/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..8a8bf9e --- /dev/null +++ b/frontend/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/android/app/src/main/assets/second_look.tflite b/frontend/android/app/src/main/assets/second_look.tflite new file mode 100644 index 0000000..be6d027 Binary files /dev/null and b/frontend/android/app/src/main/assets/second_look.tflite differ diff --git a/frontend/android/app/src/main/java/com/hexogen/secondlook/ImageLoading.kt b/frontend/android/app/src/main/java/com/hexogen/secondlook/ImageLoading.kt new file mode 100644 index 0000000..dd3d9d1 --- /dev/null +++ b/frontend/android/app/src/main/java/com/hexogen/secondlook/ImageLoading.kt @@ -0,0 +1,86 @@ +package com.hexogen.secondlook + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Matrix +import android.net.Uri +import androidx.core.content.FileProvider +import androidx.exifinterface.media.ExifInterface +import java.io.File + +/** + * Clockwise rotation, in degrees, that the image at [uri] needs to display + * upright. + * + * Camera photos are almost always stored sideways with an EXIF tag, and + * `cv2.imread(IMREAD_UNCHANGED)` ignores EXIF by design — so the preprocessor + * has to apply this itself, or the pectoral-muscle and orientation heuristics + * would run on a rotated breast. + */ +fun exifRotationDegrees(context: Context, uri: Uri): Int { + val orientation = context.contentResolver.openInputStream(uri)?.use { input -> + ExifInterface(input).getAttributeInt( + ExifInterface.TAG_ORIENTATION, + ExifInterface.ORIENTATION_NORMAL + ) + } ?: ExifInterface.ORIENTATION_NORMAL + + return when (orientation) { + ExifInterface.ORIENTATION_ROTATE_90 -> 90 + ExifInterface.ORIENTATION_ROTATE_180 -> 180 + ExifInterface.ORIENTATION_ROTATE_270 -> 270 + else -> 0 + } +} + +/** + * Decode [uri] for display, downsampled so neither edge exceeds [maxDimension] + * and rotated upright. Returns null if the image cannot be decoded. + * + * This is the preview only. The model never sees this bitmap — preprocessing + * reads the original file so it keeps the source bit depth. + */ +fun loadPreviewBitmap(context: Context, uri: Uri, maxDimension: Int = 1024): Bitmap? { + // Pass 1: dimensions only. decodeStream always returns null in this mode, + // so the stream — not the decode result — is what gets null-checked. + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + val stream = context.contentResolver.openInputStream(uri) ?: return null + stream.use { BitmapFactory.decodeStream(it, null, bounds) } + if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null + + // Pass 2: the real decode, downsampled. + val options = BitmapFactory.Options().apply { + inSampleSize = sampleSizeFor(bounds.outWidth, bounds.outHeight, maxDimension) + } + val bitmap = context.contentResolver.openInputStream(uri)?.use { + BitmapFactory.decodeStream(it, null, options) + } ?: return null + + val rotation = exifRotationDegrees(context, uri) + if (rotation == 0) return bitmap + + val matrix = Matrix().apply { postRotate(rotation.toFloat()) } + return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true) + .also { if (it != bitmap) bitmap.recycle() } +} + +/** + * A content URI the camera app can write a capture into. + * + * The file lives in the app's cache — captured mammograms never enter the + * shared media store, which is the same promise the disclaimer makes. + */ +fun createCaptureUri(context: Context): Uri { + val captures = File(context.cacheDir, "captures").apply { mkdirs() } + val file = File.createTempFile("capture_", ".jpg", captures) + return FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file) +} + +private fun sampleSizeFor(width: Int, height: Int, maxDimension: Int): Int { + var sampleSize = 1 + while (maxOf(width, height) / sampleSize > maxDimension) { + sampleSize *= 2 + } + return sampleSize +} diff --git a/frontend/android/app/src/main/java/com/hexogen/secondlook/MainActivity.kt b/frontend/android/app/src/main/java/com/hexogen/secondlook/MainActivity.kt new file mode 100644 index 0000000..540ee70 --- /dev/null +++ b/frontend/android/app/src/main/java/com/hexogen/secondlook/MainActivity.kt @@ -0,0 +1,20 @@ +package com.hexogen.secondlook + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import com.hexogen.secondlook.ui.SecondLookApp +import com.hexogen.secondlook.ui.theme.SecondLookTheme + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + SecondLookTheme { + SecondLookApp() + } + } + } +} diff --git a/frontend/android/app/src/main/java/com/hexogen/secondlook/MammogramClassifier.kt b/frontend/android/app/src/main/java/com/hexogen/secondlook/MammogramClassifier.kt new file mode 100644 index 0000000..4eb655d --- /dev/null +++ b/frontend/android/app/src/main/java/com/hexogen/secondlook/MammogramClassifier.kt @@ -0,0 +1,78 @@ +package com.hexogen.secondlook + +import android.content.Context +import android.net.Uri +import com.chaquo.python.Python +import org.tensorflow.lite.Interpreter +import java.io.FileInputStream +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.nio.MappedByteBuffer +import java.nio.channels.FileChannel + +/** + * On-device mammogram classifier: preprocess with the Python pipeline, then run + * the bundled TensorFlow Lite model. Nothing leaves the device. + * + * Not thread-safe — a TFLite [Interpreter] must be used from one thread at a + * time. Create one per scan and [close] it. + */ +class MammogramClassifier(context: Context) : AutoCloseable { + + private val appContext = context.applicationContext + private val interpreter = Interpreter(loadModel(appContext)) + + /** @property probability P(worth a second look). */ + data class Result(val probability: Float, val tier: String) + + fun classify(uri: Uri): Result { + val input = MammogramPreprocessor.preprocess(appContext, uri) + + val expectedBytes = interpreter.getInputTensor(0).numBytes() + require(input.capacity() == expectedBytes) { + "Preprocessed input is ${input.capacity()} bytes, model expects $expectedBytes" + } + + val outputTensor = interpreter.getOutputTensor(0) + val output = ByteBuffer.allocateDirect(outputTensor.numBytes()) + .order(ByteOrder.nativeOrder()) + interpreter.run(input, output) + output.rewind() + + // Single sigmoid output. Clamp before tiering: the tier function + // rejects values outside [0, 1], and float arithmetic can land a + // hair beyond either end. + val probability = output.asFloatBuffer().get(0).coerceIn(0f, 1f) + return Result(probability, tierFor(probability)) + } + + override fun close() = interpreter.close() + + companion object { + private const val MODEL_ASSET = "second_look.tflite" + + /** + * Concern tier for a probability: "Low", "Moderate" or "Elevated". + * + * Delegates to the shared Python cut-points rather than duplicating + * them here — they are provisional and uncalibrated, and will move. + */ + fun tierFor(probability: Float): String = + Python.getInstance() + .getModule("second_look_bridge") + .callAttr("tier_for", probability) + .toString() + + /** Memory-maps the model straight out of the APK (see `noCompress`). */ + private fun loadModel(context: Context): MappedByteBuffer = + context.assets.openFd(MODEL_ASSET).use { descriptor -> + FileInputStream(descriptor.fileDescriptor).use { stream -> + stream.channel.map( + FileChannel.MapMode.READ_ONLY, + descriptor.startOffset, + descriptor.declaredLength + ) + } + } + } +} diff --git a/frontend/android/app/src/main/java/com/hexogen/secondlook/MammogramPreprocessor.kt b/frontend/android/app/src/main/java/com/hexogen/secondlook/MammogramPreprocessor.kt new file mode 100644 index 0000000..e614e3c --- /dev/null +++ b/frontend/android/app/src/main/java/com/hexogen/secondlook/MammogramPreprocessor.kt @@ -0,0 +1,62 @@ +package com.hexogen.secondlook + +import android.content.Context +import android.net.Uri +import com.chaquo.python.PyObject +import com.chaquo.python.Python +import java.io.File +import java.nio.ByteBuffer +import java.nio.ByteOrder + +/** + * Runs the Second Look preprocessing pipeline on-device. + * + * The pipeline itself is the research repo's Python code, executed through + * Chaquopy (see `src/main/python/`): grayscale -> CLAHE -> breast mask -> + * pectoral removal -> orientation normalization -> resize -> float32 [0, 1]. + * Running the training code verbatim, rather than a hand-port, is the whole + * point — a port that drifts silently would feed the model inputs it was never + * trained on. + */ +object MammogramPreprocessor { + + private val bridge: PyObject + get() = Python.getInstance().getModule("second_look_bridge") + + /** Model input edge length, read from the shared Python config. */ + val inputSize: Int by lazy { bridge.callAttr("input_size").toInt() } + + /** + * Preprocess the image at [uri] into the model's input tensor. + * + * @return a direct [ByteBuffer] holding `inputSize * inputSize` float32 + * values in [0, 1], laid out for a `[1, size, size, 1]` tensor. + * @throws java.io.IOException if the image cannot be read. + * @throws com.chaquo.python.PyException if the pipeline rejects the image + * (empty, corrupt, or an unsupported number of channels). + */ + fun preprocess(context: Context, uri: Uri): ByteBuffer { + // The pipeline reads from disk with cv2.imread(IMREAD_UNCHANGED) to keep + // 16-bit sources at full depth, so hand it a real file rather than + // decoding to a Bitmap here (which would flatten to 8-bit RGBA first). + val scratch = File.createTempFile("second_look_input", null, context.cacheDir) + try { + context.contentResolver.openInputStream(uri)?.use { input -> + scratch.outputStream().use(input::copyTo) + } ?: throw java.io.IOException("Could not open image: $uri") + + val bytes = bridge + .callAttr("preprocess_file", scratch.absolutePath, exifRotationDegrees(context, uri)) + .toJava(ByteArray::class.java) + + // TFLite requires a direct buffer. Python wrote little-endian + // float32, which is what nativeOrder() is on every supported ABI. + return ByteBuffer.allocateDirect(bytes.size) + .order(ByteOrder.nativeOrder()) + .put(bytes) + .apply { rewind() } + } finally { + scratch.delete() + } + } +} diff --git a/frontend/android/app/src/main/java/com/hexogen/secondlook/SecondLookApplication.kt b/frontend/android/app/src/main/java/com/hexogen/secondlook/SecondLookApplication.kt new file mode 100644 index 0000000..510a8b0 --- /dev/null +++ b/frontend/android/app/src/main/java/com/hexogen/secondlook/SecondLookApplication.kt @@ -0,0 +1,20 @@ +package com.hexogen.secondlook + +import android.app.Application +import com.chaquo.python.Python +import com.chaquo.python.android.AndroidPlatform + +/** + * Starts the embedded CPython runtime once per process. + * + * Chaquopy extracts its stdlib and the NumPy/OpenCV wheels on first launch, so + * doing this at application start keeps the cost off the scanning path. + */ +class SecondLookApplication : Application() { + override fun onCreate() { + super.onCreate() + if (!Python.isStarted()) { + Python.start(AndroidPlatform(this)) + } + } +} diff --git a/frontend/android/app/src/main/java/com/hexogen/secondlook/ui/SecondLookFlow.kt b/frontend/android/app/src/main/java/com/hexogen/secondlook/ui/SecondLookFlow.kt new file mode 100644 index 0000000..b99fd03 --- /dev/null +++ b/frontend/android/app/src/main/java/com/hexogen/secondlook/ui/SecondLookFlow.kt @@ -0,0 +1,624 @@ +package com.hexogen.secondlook.ui + +import android.content.Context +import android.content.pm.PackageManager +import android.graphics.Bitmap +import android.net.Uri +import android.util.Log +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.DrawableRes +import androidx.compose.animation.Crossfade +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.toggleable +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.scale +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.hexogen.secondlook.MammogramClassifier +import com.hexogen.secondlook.R +import com.hexogen.secondlook.createCaptureUri +import com.hexogen.secondlook.loadPreviewBitmap +import com.hexogen.secondlook.ui.theme.SecondLookTheme +import com.hexogen.secondlook.ui.theme.TierColors +import com.hexogen.secondlook.ui.theme.iOSSwitchColors +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlin.math.roundToInt + +private const val TAG = "SecondLook" + +// MARK: - Flow state + +enum class AppStage { Disclaimer, Capture, Scanning, Results } + +// MARK: - Root + +@Composable +fun SecondLookApp() { + var stage by rememberSaveable { mutableStateOf(AppStage.Disclaimer) } + var selectedImage by rememberSaveable { mutableStateOf(null) } + var result by remember { mutableStateOf(null) } + + Box( + Modifier + .fillMaxSize() + // Calm background for the whole app. + .background( + Brush.verticalGradient( + listOf( + MaterialTheme.colorScheme.background, + MaterialTheme.colorScheme.surfaceVariant + ) + ) + ) + .safeDrawingPadding() + ) { + Crossfade(targetState = stage, label = "stage") { current -> + when (current) { + AppStage.Disclaimer -> DisclaimerGate(onAccept = { stage = AppStage.Capture }) + + AppStage.Capture -> CaptureScreen(onImageSelected = { uri -> + selectedImage = uri + stage = AppStage.Scanning + }) + + AppStage.Scanning -> ScanningScreen( + image = selectedImage, + onComplete = { classification -> + result = classification + stage = AppStage.Results + } + ) + + AppStage.Results -> ResultsScreen( + image = selectedImage, + result = result, + onDone = { + // Back to capture — the disclaimer stays accepted for this session. + selectedImage = null + result = null + stage = AppStage.Capture + } + ) + } + } + } +} + +// MARK: - 1. Disclaimer gate + +@Composable +private fun DisclaimerGate(onAccept: () -> Unit) { + var hasAgreed by rememberSaveable { mutableStateOf(false) } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(20.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(Modifier.weight(1f)) + + HeroIcon(R.drawable.ic_medical_case) + Spacer(Modifier.height(24.dp)) + Text( + text = stringResource(R.string.disclaimer_title), + style = MaterialTheme.typography.displaySmall, + fontWeight = FontWeight.Bold + ) + Spacer(Modifier.height(16.dp)) + Text( + text = stringResource(R.string.disclaimer_body), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.padding(horizontal = 8.dp) + ) + + Spacer(Modifier.weight(1f)) + + Surface( + shape = RoundedCornerShape(14.dp), + color = MaterialTheme.colorScheme.surfaceVariant, + modifier = Modifier + .fillMaxWidth() + .toggleable( + value = hasAgreed, + role = Role.Switch, + onValueChange = { hasAgreed = it } + ) + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(R.string.disclaimer_agreement), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f) + ) + Spacer(Modifier.width(12.dp)) + Switch( + checked = hasAgreed, + onCheckedChange = null, + colors = iOSSwitchColors() + ) + } + } + + Spacer(Modifier.height(20.dp)) + + PrimaryButton( + text = stringResource(R.string.disclaimer_continue), + enabled = hasAgreed, + onClick = onAccept + ) + } +} + +// MARK: - 2. Upload / capture + +@Composable +private fun CaptureScreen(onImageSelected: (Uri) -> Unit) { + val context = LocalContext.current + var pendingCapture by remember { mutableStateOf(null) } + + val pickImage = rememberLauncherForActivityResult( + ActivityResultContracts.PickVisualMedia() + ) { uri -> uri?.let(onImageSelected) } + + val takePhoto = rememberLauncherForActivityResult( + ActivityResultContracts.TakePicture() + ) { saved -> if (saved) pendingCapture?.let(onImageSelected) } + + val hasCamera = remember { + context.packageManager.hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY) + } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(20.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(Modifier.weight(1f)) + + HeroIcon(R.drawable.ic_add_photo) + Spacer(Modifier.height(20.dp)) + Text( + text = stringResource(R.string.capture_title), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold + ) + Spacer(Modifier.height(8.dp)) + Text( + text = stringResource(R.string.capture_body), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.padding(horizontal = 24.dp) + ) + + Spacer(Modifier.height(28.dp)) + + PrimaryButton( + text = stringResource(R.string.capture_choose_image), + icon = R.drawable.ic_photo_library, + modifier = Modifier.padding(horizontal = 20.dp), + onClick = { + pickImage.launch( + PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly) + ) + } + ) + + Spacer(Modifier.height(12.dp)) + + PrimaryButton( + text = stringResource(R.string.capture_take_photo), + icon = R.drawable.ic_camera, + enabled = hasCamera, + modifier = Modifier.padding(horizontal = 20.dp), + onClick = { + val uri = createCaptureUri(context) + pendingCapture = uri + takePhoto.launch(uri) + } + ) + + Spacer(Modifier.weight(1f)) + } +} + +// MARK: - 3. Scanning + +@Composable +private fun ScanningScreen( + image: Uri?, + onComplete: (MammogramClassifier.Result?) -> Unit +) { + val context = LocalContext.current + val currentOnComplete by rememberUpdatedState(onComplete) + + LaunchedClassification(image, context.applicationContext, currentOnComplete) + + val pulse = rememberInfiniteTransition(label = "pulse") + val scale by pulse.animateFloat( + initialValue = 0.9f, + targetValue = 1.15f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 1000, easing = FastOutSlowInEasing), + repeatMode = RepeatMode.Reverse + ), + label = "scale" + ) + + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Box(contentAlignment = Alignment.Center) { + Box( + Modifier + .size(120.dp) + .scale(scale) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)) + ) + Icon( + painter = painterResource(R.drawable.ic_pulse_search), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(48.dp) + ) + } + + Spacer(Modifier.height(24.dp)) + Text( + text = stringResource(R.string.scanning_title), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + Spacer(Modifier.height(10.dp)) + IconLabel( + icon = R.drawable.ic_lock, + text = stringResource(R.string.scanning_privacy), + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall + ) + } +} + +/** + * Runs preprocessing + inference off the main thread, exactly once per image. + * + * A failure here is not exceptional — an unreadable file or an image the + * pipeline cannot segment both land here — so it degrades to a null result, + * which the results screen renders as "Analysis Unavailable". + */ +@Composable +private fun LaunchedClassification( + image: Uri?, + context: Context, + onComplete: (MammogramClassifier.Result?) -> Unit +) { + LaunchedEffect(image) { + val outcome = withContext(Dispatchers.Default) { + runCatching { + val uri = requireNotNull(image) { "No image was selected" } + MammogramClassifier(context).use { it.classify(uri) } + } + } + outcome.exceptionOrNull()?.let { Log.e(TAG, "Inference failed", it) } + onComplete(outcome.getOrNull()) + } +} + +// MARK: - 4. Results + +@Composable +private fun ResultsScreen( + image: Uri?, + result: MammogramClassifier.Result?, + onDone: () -> Unit +) { + val context = LocalContext.current + val preview by produceState(initialValue = null, image) { + value = image?.let { withContext(Dispatchers.IO) { loadPreviewBitmap(context, it) } } + } + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 12.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + preview?.let { bitmap -> + Image( + bitmap = bitmap.asImageBitmap(), + contentDescription = stringResource(R.string.cd_selected_image), + contentScale = ContentScale.Fit, + // No fillMaxWidth: letting the image size itself within the + // height cap keeps the layout bounds on the picture, so the + // shadow hugs the image instead of an empty letterbox. + modifier = Modifier + .heightIn(max = 320.dp) + .shadow(elevation = 10.dp, shape = RoundedCornerShape(16.dp)) + .clip(RoundedCornerShape(16.dp)) + ) + Spacer(Modifier.height(20.dp)) + } + + TierBadge(result) + + Spacer(Modifier.height(16.dp)) + + Surface( + shape = RoundedCornerShape(14.dp), + color = MaterialTheme.colorScheme.surfaceVariant, + modifier = Modifier.fillMaxWidth() + ) { + Column(Modifier.padding(16.dp)) { + IconLabel( + icon = R.drawable.ic_info, + text = stringResource(R.string.results_meaning_heading), + style = MaterialTheme.typography.titleSmall + ) + Spacer(Modifier.height(8.dp)) + Text( + text = stringResource( + if (result == null) R.string.results_meaning_error + else R.string.results_meaning_body + ), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + HorizontalDivider(Modifier.padding(vertical = 14.dp)) + + IconLabel( + icon = R.drawable.ic_stethoscope, + text = stringResource(R.string.results_next_step_heading), + style = MaterialTheme.typography.titleSmall + ) + Spacer(Modifier.height(8.dp)) + Text( + text = stringResource(R.string.results_next_step_body), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + Spacer(Modifier.height(24.dp)) + + PrimaryButton( + text = stringResource(R.string.results_scan_another), + icon = R.drawable.ic_refresh, + onClick = onDone + ) + } +} + +@Composable +private fun TierBadge(result: MammogramClassifier.Result?) { + val tierColor = when (result?.tier) { + null -> TierColors.unavailable + "Low" -> TierColors.low + "Moderate" -> TierColors.moderate + else -> TierColors.elevated + } + val tierIcon = when (result?.tier) { + null -> R.drawable.ic_help + "Low" -> R.drawable.ic_shield_check + "Moderate" -> R.drawable.ic_shield_alert + else -> R.drawable.ic_warning + } + val tierTitle = stringResource( + when (result?.tier) { + null -> R.string.results_tier_unavailable + "Low" -> R.string.results_tier_low + "Moderate" -> R.string.results_tier_moderate + else -> R.string.results_tier_elevated + } + ) + + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(14.dp)) + // SwiftUI's `tierColor.gradient`: the same hue, lifted slightly at + // the top and shaded at the bottom. + .background( + Brush.verticalGradient( + listOf( + lerp(tierColor, Color.White, 0.05f), + lerp(tierColor, Color.Black, 0.15f) + ) + ) + ) + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = painterResource(tierIcon), + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(28.dp) + ) + Spacer(Modifier.width(12.dp)) + Column(Modifier.weight(1f)) { + Text( + text = stringResource(R.string.results_tier_label), + style = MaterialTheme.typography.labelMedium, + color = Color.White.copy(alpha = 0.8f) + ) + Text( + text = tierTitle, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = Color.White + ) + } + result?.let { + Text( + text = stringResource( + R.string.results_confidence, + (it.probability * 100).roundToInt() + ), + style = MaterialTheme.typography.labelLarge, + color = Color.White, + modifier = Modifier + .clip(CircleShape) + .background(Color.White.copy(alpha = 0.22f)) + .padding(horizontal = 10.dp, vertical = 5.dp) + ) + } + } +} + +// MARK: - Shared pieces + +@Composable +private fun HeroIcon(@DrawableRes icon: Int) { + Box( + modifier = Modifier + .size(140.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)), + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(icon), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(56.dp) + ) + } +} + +@Composable +private fun PrimaryButton( + text: String, + modifier: Modifier = Modifier, + @DrawableRes icon: Int? = null, + enabled: Boolean = true, + onClick: () -> Unit +) { + Button( + onClick = onClick, + enabled = enabled, + shape = RoundedCornerShape(14.dp), + modifier = modifier + .fillMaxWidth() + .height(52.dp) + ) { + icon?.let { + Icon(painterResource(it), contentDescription = null, modifier = Modifier.size(20.dp)) + Spacer(Modifier.width(8.dp)) + } + Text(text, style = MaterialTheme.typography.titleSmall) + } +} + +@Composable +private fun IconLabel( + @DrawableRes icon: Int, + text: String, + modifier: Modifier = Modifier, + color: Color = MaterialTheme.colorScheme.onSurface, + style: TextStyle = MaterialTheme.typography.titleSmall +) { + Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) { + Icon( + painter = painterResource(icon), + contentDescription = null, + tint = color, + modifier = Modifier.size(18.dp) + ) + Spacer(Modifier.width(8.dp)) + Text(text = text, style = style, color = color, fontWeight = FontWeight.SemiBold) + } +} + +// MARK: - Previews + +@Preview(showBackground = true) +@Composable +private fun DisclaimerPreview() { + SecondLookTheme { DisclaimerGate(onAccept = {}) } +} + +@Preview(showBackground = true) +@Composable +private fun ResultsPreview() { + SecondLookTheme { + ResultsScreen( + image = null, + result = MammogramClassifier.Result(probability = 0.42f, tier = "Moderate"), + onDone = {} + ) + } +} diff --git a/frontend/android/app/src/main/java/com/hexogen/secondlook/ui/theme/Color.kt b/frontend/android/app/src/main/java/com/hexogen/secondlook/ui/theme/Color.kt new file mode 100644 index 0000000..c4c76e0 --- /dev/null +++ b/frontend/android/app/src/main/java/com/hexogen/secondlook/ui/theme/Color.kt @@ -0,0 +1,48 @@ +package com.hexogen.secondlook.ui.theme + +import androidx.compose.ui.graphics.Color + +// Apple's system colors, so this app renders the same as the SwiftUI build. +// Each name maps to the UIKit/SwiftUI color the iOS app asks for: +// +// ContentView iOS color constant below +// --------------- ----------------------- ---------------------- +// background top .systemBackground SystemBackground* +// background bottom .systemGray6 SystemGray6* +// cards .secondarySystemBackground SecondarySystemBackground* +// .tint / accent accentColor (system blue) SystemBlue* +// tier Low .green SystemGreen* +// tier Moderate .orange SystemOrange* +// tier Elevated .red SystemRed* +// unavailable .gray SystemGray* +// .foregroundStyle(.secondary) SecondaryLabel* + +// --- Light --- +val SystemBackgroundLight = Color(0xFFFFFFFF) +val SystemGray6Light = Color(0xFFF2F2F7) +val SecondarySystemBackgroundLight = Color(0xFFF2F2F7) +val LabelLight = Color(0xFF000000) +val SecondaryLabelLight = Color(0x993C3C43) // #3C3C43 @ 60% +val SeparatorLight = Color(0x4A3C3C43) // #3C3C43 @ 29% + +val SystemBlueLight = Color(0xFF007AFF) +val SystemGreenLight = Color(0xFF34C759) +val SystemOrangeLight = Color(0xFFFF9500) +val SystemRedLight = Color(0xFFFF3B30) +val SystemGrayLight = Color(0xFF8E8E93) +val SwitchOffTrackLight = Color(0xFFE9E9EA) + +// --- Dark --- +val SystemBackgroundDark = Color(0xFF000000) +val SystemGray6Dark = Color(0xFF1C1C1E) +val SecondarySystemBackgroundDark = Color(0xFF1C1C1E) +val LabelDark = Color(0xFFFFFFFF) +val SecondaryLabelDark = Color(0x99EBEBF5) // #EBEBF5 @ 60% +val SeparatorDark = Color(0xA6545458) // #545458 @ 65% + +val SystemBlueDark = Color(0xFF0A84FF) +val SystemGreenDark = Color(0xFF30D158) +val SystemOrangeDark = Color(0xFFFF9F0A) +val SystemRedDark = Color(0xFFFF453A) +val SystemGrayDark = Color(0xFF8E8E93) +val SwitchOffTrackDark = Color(0xFF39393D) diff --git a/frontend/android/app/src/main/java/com/hexogen/secondlook/ui/theme/Theme.kt b/frontend/android/app/src/main/java/com/hexogen/secondlook/ui/theme/Theme.kt new file mode 100644 index 0000000..e21fa93 --- /dev/null +++ b/frontend/android/app/src/main/java/com/hexogen/secondlook/ui/theme/Theme.kt @@ -0,0 +1,81 @@ +package com.hexogen.secondlook.ui.theme + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SwitchColors +import androidx.compose.material3.SwitchDefaults +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.ui.graphics.Color + +private val DarkColorScheme = darkColorScheme( + primary = SystemBlueDark, + onPrimary = Color.White, + background = SystemBackgroundDark, + onBackground = LabelDark, + surface = SystemBackgroundDark, + onSurface = LabelDark, + // The app's vertical background gradient ends on systemGray6, and cards use + // secondarySystemBackground — the same value on iOS, so one slot covers both. + surfaceVariant = SystemGray6Dark, + onSurfaceVariant = SecondaryLabelDark, + outlineVariant = SeparatorDark +) + +private val LightColorScheme = lightColorScheme( + primary = SystemBlueLight, + onPrimary = Color.White, + background = SystemBackgroundLight, + onBackground = LabelLight, + surface = SystemBackgroundLight, + onSurface = LabelLight, + surfaceVariant = SystemGray6Light, + onSurfaceVariant = SecondaryLabelLight, + outlineVariant = SeparatorLight +) + +/** + * Concern-tier colors: SwiftUI's `.green` / `.orange` / `.red` / `.gray`. + * + * Kept outside the Material color scheme because they carry meaning — they must + * not shift with dynamic color or the user's wallpaper. + */ +object TierColors { + val low: Color @Composable @ReadOnlyComposable get() = pick(SystemGreenLight, SystemGreenDark) + val moderate: Color @Composable @ReadOnlyComposable get() = pick(SystemOrangeLight, SystemOrangeDark) + val elevated: Color @Composable @ReadOnlyComposable get() = pick(SystemRedLight, SystemRedDark) + val unavailable: Color @Composable @ReadOnlyComposable get() = pick(SystemGrayLight, SystemGrayDark) + + @Composable + @ReadOnlyComposable + private fun pick(light: Color, dark: Color) = if (isSystemInDarkTheme()) dark else light +} + +/** + * A SwiftUI `Toggle`: green when on, white thumb, no border — rather than + * Material's primary-tinted switch. + */ +@Composable +fun iOSSwitchColors(): SwitchColors = SwitchDefaults.colors( + checkedThumbColor = Color.White, + checkedTrackColor = TierColors.low, + checkedBorderColor = Color.Transparent, + uncheckedThumbColor = Color.White, + uncheckedTrackColor = if (isSystemInDarkTheme()) SwitchOffTrackDark else SwitchOffTrackLight, + uncheckedBorderColor = Color.Transparent +) + +@Composable +fun SecondLookTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + content: @Composable () -> Unit +) { + // No dynamic color: the iOS app has a fixed palette, and this one matches it. + MaterialTheme( + colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme, + typography = Typography, + content = content + ) +} diff --git a/frontend/android/app/src/main/java/com/hexogen/secondlook/ui/theme/Type.kt b/frontend/android/app/src/main/java/com/hexogen/secondlook/ui/theme/Type.kt new file mode 100644 index 0000000..ab143a3 --- /dev/null +++ b/frontend/android/app/src/main/java/com/hexogen/secondlook/ui/theme/Type.kt @@ -0,0 +1,34 @@ +package com.hexogen.secondlook.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +// Set of Material typography styles to start with +val Typography = Typography( + bodyLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp + ) + /* Other default text styles to override + titleLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp + ), + labelSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ) + */ +) \ No newline at end of file diff --git a/frontend/android/app/src/main/keepRules/rules.keep b/frontend/android/app/src/main/keepRules/rules.keep new file mode 100644 index 0000000..d7e081a --- /dev/null +++ b/frontend/android/app/src/main/keepRules/rules.keep @@ -0,0 +1,12 @@ +# Add project specific R8 rules here. +# AGP will combine all keep rule files in src/main/keepRules to pass to R8 +# +# For more details, see +# https://d.android.com/r/tools/r8/keep-rules + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} \ No newline at end of file diff --git a/frontend/android/app/src/main/python/config/__init__.py b/frontend/android/app/src/main/python/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/frontend/android/app/src/main/python/config/constants.py b/frontend/android/app/src/main/python/config/constants.py new file mode 100644 index 0000000..2feaf6a --- /dev/null +++ b/frontend/android/app/src/main/python/config/constants.py @@ -0,0 +1,8 @@ +# Central constants for Second Look. Fixed per CLAUDE.md design decisions. + +INPUT_SIZE = (224, 224) +SEED = 42 + +TRAIN_RATIO = 0.70 +VAL_RATIO = 0.15 +TEST_RATIO = 0.15 diff --git a/frontend/android/app/src/main/python/data_pipeline/__init__.py b/frontend/android/app/src/main/python/data_pipeline/__init__.py new file mode 100644 index 0000000..1043cb6 --- /dev/null +++ b/frontend/android/app/src/main/python/data_pipeline/__init__.py @@ -0,0 +1,15 @@ +"""Second Look data pipeline — on-device subset. + +This is NOT the research repo's ``data_pipeline/__init__.py``. That one eagerly +imports the whole training pipeline (pandas, PyYAML, google-cloud-storage, +TensorFlow), none of which is available — or wanted — inside the app. Only the +inference-time modules are vendored here: + + _imaging_utils.py grayscale + breast mask primitives + preprocessor.py the preprocessing pipeline the model was trained with + label_mapper.py probability -> concern tier + +Those three files are copied verbatim from the research repo; keep them that +way so training-time and on-device preprocessing cannot diverge. Refresh them +with ``./gradlew :app:syncPythonPipeline``. +""" diff --git a/frontend/android/app/src/main/python/data_pipeline/_imaging_utils.py b/frontend/android/app/src/main/python/data_pipeline/_imaging_utils.py new file mode 100644 index 0000000..d4ca715 --- /dev/null +++ b/frontend/android/app/src/main/python/data_pipeline/_imaging_utils.py @@ -0,0 +1,53 @@ +"""Shared low-level imaging primitives for the data pipeline. + +Thin, dependency-light helpers (cv2 + numpy only — no pipeline imports) used by +both ``preprocessor`` and ``quality``. Extracted so the grayscale + breast-mask +logic lives in exactly one place; previously each module kept a verbatim copy to +dodge a circular import, which risked the two copies silently diverging as the +masking logic evolves. +""" + +import cv2 +import numpy as np + + +def to_grayscale(image: np.ndarray) -> np.ndarray: + """Collapse an image to a single-channel 2D array. + + Accepts 2D grayscale, (H, W, 1), 3-channel BGR, or 4-channel BGRA. The + source bit depth (uint8 / uint16) is preserved. + """ + if image.ndim == 2: + return image + if image.ndim == 3 and image.shape[2] == 1: + return np.ascontiguousarray(image[:, :, 0]) + if image.ndim == 3 and image.shape[2] == 3: + return cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) + if image.ndim == 3 and image.shape[2] == 4: + return cv2.cvtColor(image, cv2.COLOR_BGRA2GRAY) + raise ValueError(f"Unsupported image shape: {image.shape}") + + +def breast_mask(gray: np.ndarray) -> np.ndarray: + """Binary mask isolating breast tissue from background. + + Uses Otsu thresholding + largest-connected-component selection + a + morphological close to fill small holes. Returns a uint8 mask where + 255 = tissue, 0 = background. + """ + _, binary = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) + + # Keep only the largest connected component (the breast). + num_labels, labels, stats, _ = cv2.connectedComponentsWithStats(binary, connectivity=8) + if num_labels < 2: + # Fallback: return the full image as mask if segmentation fails. + return binary + + # Label 0 is background; find the largest foreground component. + largest = 1 + np.argmax(stats[1:, cv2.CC_STAT_AREA]) + mask = np.where(labels == largest, 255, 0).astype(np.uint8) + + # Morphological close to fill small holes in tissue. + kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (15, 15)) + mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel) + return mask diff --git a/frontend/android/app/src/main/python/data_pipeline/label_mapper.py b/frontend/android/app/src/main/python/data_pipeline/label_mapper.py new file mode 100644 index 0000000..611f753 --- /dev/null +++ b/frontend/android/app/src/main/python/data_pipeline/label_mapper.py @@ -0,0 +1,306 @@ +# Maps dataset-specific labels to a unified "Second Look" decision. + +# Design rationale: +# Each dataset encodes outcomes differently: +# - CBIS-DDSM → pathology (post-biopsy) +# - RSNA → cancer classification (0/1) +# - VinDr → BI-RADS categories (radiologist assessment) +# These are not directly comparable, so we map them into a shared, +# non-clinical decision space. + + +# Mapping principle: +# WORTH_SECOND_LOOK → requires recall, follow-up, or biopsy +# NOT_WORTH_SECOND_LOOK → confidently non-actionable + + +# Dataset-specific interpretation: +# +# CBIS-DDSM (biopsy-driven): +# MALIGNANT → WORTH_SECOND_LOOK +# BENIGN → WORTH_SECOND_LOOK (biopsy performed → suspicious) +# BENIGN_WITHOUT_CALLBACK → NOT_WORTH_SECOND_LOOK +# +# RSNA (screening-scale): +# cancer = 1 → WORTH_SECOND_LOOK +# cancer = 0 → NOT_WORTH_SECOND_LOOK +# +# VinDr-Mammo (BI-RADS-based): +# BI-RADS 1–3 → NOT_WORTH_SECOND_LOOK +# BI-RADS 4–5 → WORTH_SECOND_LOOK +# (Matches the INbreast rule; unified across BI-RADS datasets.) + +# Safety note: +# Unknown labels raise ValueError rather than defaulting silently. +# Per the failure mode hierarchy, unrecognized input must never +# produce confident output. + +from enum import Enum +from typing import Any + + +class Label(Enum): + """ + Canonical binary label used across all datasets. + + Attributes: + WORTH_SECOND_LOOK: + Case warrants additional diagnostic attention. + + NOT_WORTH_SECOND_LOOK: + Case is confidently non-actionable. + """ + WORTH_SECOND_LOOK = 1 + NOT_WORTH_SECOND_LOOK = 0 + + +# --- CBIS-DDSM --- +CBIS_MAP = { + "MALIGNANT": Label.WORTH_SECOND_LOOK, + "BENIGN": Label.WORTH_SECOND_LOOK, + "BENIGN_WITHOUT_CALLBACK": Label.NOT_WORTH_SECOND_LOOK, +} + + +def map_cbis(pathology: str) -> Label: + """ + Map CBIS-DDSM pathology to Label. + + MALIGNANT and BENIGN → WORTH_SECOND_LOOK + BENIGN_WITHOUT_CALLBACK → NOT_WORTH_SECOND_LOOK + + Raises: + ValueError: If label is unknown. + """ + # Normalize string to match dictionary keys + key = pathology.strip().upper().replace(" ", "_") + + if key not in CBIS_MAP: + raise ValueError(f"Unknown CBIS label: {pathology}") + + return CBIS_MAP[key] + + +# --- RSNA --- +def map_rsna(cancer: int) -> Label: + """ + Map RSNA cancer label (0/1) to Label. + + Raises: + ValueError: If input is not 0 or 1. + """ + if cancer not in (0, 1): + raise ValueError(f"Invalid RSNA label: {cancer}") + + return Label.WORTH_SECOND_LOOK if cancer == 1 else Label.NOT_WORTH_SECOND_LOOK + + +# --- VinDr --- +def map_vindr(birads) -> Label: + """ + Map BI-RADS (e.g. 'BI-RADS 4') to Label. + + BI-RADS 4–5 → WORTH_SECOND_LOOK + BI-RADS 1–3 → NOT_WORTH_SECOND_LOOK + + Unified with the INbreast rule so BI-RADS datasets share a single + binary cut-point. + """ + # Handle string format like "BI-RADS 4" + if isinstance(birads, str): + # Extract digits only + digits = "".join(filter(str.isdigit, birads)) + if not digits: + raise ValueError(f"Invalid BI-RADS string: {birads}") + birads = int(digits) + + if birads < 0 or birads > 6: + raise ValueError(f"Invalid BI-RADS value: {birads}. Expected 0–6.") + + return Label.WORTH_SECOND_LOOK if birads >= 4 else Label.NOT_WORTH_SECOND_LOOK + + +# --- INbreast --- +# Project decision: BI-RADS 1–3 → NOT_WORTH, 4–5 → WORTH. +# This is stricter than the VinDr rule (which places BI-RADS 3 on the WORTH side) +# and is specific to INbreast per the project owner. +def map_inbreast(birads) -> Label: + """ + Map INbreast BI-RADS (int or 'BI-RADS N' string) to Label. + + BI-RADS 4–5 → WORTH_SECOND_LOOK + BI-RADS 1–3 → NOT_WORTH_SECOND_LOOK + """ + if isinstance(birads, str): + digits = "".join(filter(str.isdigit, birads)) + if not digits: + raise ValueError(f"Invalid BI-RADS string: {birads}") + birads = int(digits) + + if birads not in (1, 2, 3, 4, 5): + raise ValueError(f"Invalid INbreast BI-RADS value: {birads}. Expected 1–5.") + + return Label.WORTH_SECOND_LOOK if birads >= 4 else Label.NOT_WORTH_SECOND_LOOK + + +def map_dataset(dataset: str, value) -> Label: + """ + Map a dataset-specific label to the unified Label. + + Args: + dataset: One of {'cbis', 'rsna', 'vindr', 'inbreast'}. + value: Raw label value for that dataset. + + Returns: + Label enum. + + Raises: + ValueError: If dataset is unknown or input is invalid. + """ + dataset = dataset.lower() # normalize input + + if dataset == "cbis": + return map_cbis(value) + elif dataset == "rsna": + return map_rsna(value) + elif dataset == "vindr": + return map_vindr(value) + elif dataset == "inbreast": + return map_inbreast(value) + else: + # Explicit failure for unknown dataset + raise ValueError(f"Unknown dataset: {dataset}") + + +def to_int(label: Label) -> int: + """ + Convert Label enum to integer (0/1). + + Returns: + 1 for WORTH_SECOND_LOOK, 0 for NOT_WORTH_SECOND_LOOK. + + Notes: + Use this for model training (e.g., TensorFlow, PyTorch), + where numeric targets are required. + """ + return label.value + + +# --- UX tier rendering (decoupled from model labels per CLAUDE.md) --- +# +# The model head is binary. The three tiers below are a UX-layer +# presentation concern derived from model confidence, NOT from the model +# output directly. Keeping these functions in the same module (but in a +# clearly separated block) so the single call site +# `display_label(confidence_to_tier(prob))` is discoverable without +# importing from two places. + +VALID_TIERS = {"Low", "Moderate", "Elevated"} + +TIER_DISPLAY_LABELS = { + "Low": "Low Area of Interest", + "Moderate": "Moderate Area of Interest", + "Elevated": "Elevated Area of Interest", +} + +# Tier cut-points on the positive-class probability: +# prob < low_max → Low +# prob < moderate_max → Moderate +# otherwise → Elevated +# +# PROVISIONAL — these evenly-spaced defaults are placeholders so the UI has +# something functional to render during integration. They are NOT clinically +# calibrated. calibrate_thresholds() is the home for replacing them with +# asymmetric, sensitivity-favoring cuts once validation data exists. +TIER_THRESHOLDS = {"low_max": 0.33, "moderate_max": 0.66} + + +def confidence_to_tier(prob: float, thresholds: dict[str, float] = TIER_THRESHOLDS) -> str: + """Map a positive-class probability to a UX concern tier. + + The cut-points come from ``thresholds`` (defaults to the provisional + ``TIER_THRESHOLDS``). Pass a calibrated dict from ``calibrate_thresholds`` + once validation data is available; the defaults are NOT clinically + calibrated and the real cuts will almost certainly be asymmetric (pushing + the Elevated threshold lower to favor sensitivity per the failure-mode + hierarchy). + + Args: + prob: Positive-class probability in [0.0, 1.0] — the sigmoid output + of the binary Second Look model. + thresholds: Mapping with 'low_max' and 'moderate_max' cut-points. + + Returns: + One of 'Low', 'Moderate', 'Elevated'. + + Raises: + ValueError: If prob is outside [0.0, 1.0]. + """ + if not (0.0 <= prob <= 1.0): + raise ValueError(f"Probability out of range [0, 1]: {prob}") + + if prob < thresholds["low_max"]: + return "Low" + if prob < thresholds["moderate_max"]: + return "Moderate" + return "Elevated" + + +def calibrate_thresholds(val_df: Any, model: Any) -> dict[str, float]: + """Derive calibrated tier cut-points from validation data. NOT YET IMPLEMENTED. + + This is the designated home for replacing the provisional, evenly-spaced + ``TIER_THRESHOLDS`` with data-driven cut-points. The intended contract: + + 1. Run ``model`` over the validation images in ``val_df`` to get a + positive-class probability per case, paired with its true binary label. + 2. Choose ``moderate_max`` (the Moderate→Elevated boundary) as the + operating point that meets the positive-class sensitivity requirement — + ``WORTH_SENSITIVITY_FLOOR`` in ``modeling.baseline_classifier`` (0.80). + Per the failure-mode hierarchy, this cut is deliberately ASYMMETRIC: + pushed lower than a balanced 0.66 so borderline cases escalate to + Elevated rather than risk false reassurance. + 3. Choose ``low_max`` (the Low→Moderate boundary) to keep the Low tier + high-specificity — only confidently non-actionable cases land in Low. + + Args: + val_df: Validation split (e.g. a manifest DataFrame) with image paths + and binary ``canonical_label`` values. + model: Trained binary classifier exposing a ``predict``-style call that + returns positive-class probabilities. + + Returns: + A dict with 'low_max' and 'moderate_max', drop-in compatible with + ``confidence_to_tier(prob, thresholds=...)``. + + Raises: + NotImplementedError: Always, until calibration is implemented against a + trained model and a built validation split. + """ + raise NotImplementedError( + "Tier threshold calibration is not implemented yet. " + "confidence_to_tier() currently uses the provisional TIER_THRESHOLDS. " + "See this function's docstring for the intended calibration contract." + ) + + +def display_label(tier: str) -> str: + """Return the UI-safe display label for a concern tier. + + Use this whenever rendering tier text in the app or logs. + Never expose raw tier strings or clinical terms to the user. + + Args: + tier: One of 'Low', 'Moderate', 'Elevated'. + + Returns: + A calm, non-diagnostic display string. + + Raises: + ValueError: If tier is not a valid concern tier. + """ + if tier not in VALID_TIERS: + raise ValueError( + f"'{tier}' is not a valid concern tier. Expected one of: {sorted(VALID_TIERS)}" + ) + return TIER_DISPLAY_LABELS[tier] diff --git a/frontend/android/app/src/main/python/data_pipeline/preprocessor.py b/frontend/android/app/src/main/python/data_pipeline/preprocessor.py new file mode 100644 index 0000000..7a43e08 --- /dev/null +++ b/frontend/android/app/src/main/python/data_pipeline/preprocessor.py @@ -0,0 +1,203 @@ +# Preprocessing pipeline for Second Look. + +# Produces normalized arrays ready for training / TF Lite inference. + +# Steps applied in order: +# 1. Convert to grayscale (mammograms are single-channel) +# 2. CLAHE — improves local contrast without amplifying noise globally +# 3. Breast masking — zeros out background (air), leaving only tissue +# 4. Pectoral muscle removal — removes the dense triangle in MLO views +# 5. Orientation normalization — flips so the breast faces right consistently +# 6. Resize + normalize to [0, 1] float32 + +# Output shape: (H, W, 1) — single channel, float32. + +# Input-quality gating lives in data_pipeline.quality (quality_check). + +from pathlib import Path + +import cv2 +import numpy as np + +from config.constants import INPUT_SIZE +from data_pipeline._imaging_utils import breast_mask, to_grayscale + + +# Default target size: MobileNetV2 / EfficientNetB0 standard input, per CLAUDE.md. +DEFAULT_SIZE = INPUT_SIZE # (224, 224) + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + +def preprocess(image: np.ndarray, target_size: tuple = DEFAULT_SIZE) -> np.ndarray: + """Full preprocessing pipeline for a single mammogram image. + + Args: + image: Raw image as a numpy array (grayscale or RGB, uint8 or uint16). + target_size: (width, height) to resize to after all preprocessing steps. + + Returns: + Float32 numpy array of shape (H, W, 1), values in [0, 1]. + + Raises: + ValueError: If the input array is empty or has an unsupported number of channels. + """ + if image is None or image.size == 0: + raise ValueError("Received an empty image array.") + + gray = to_grayscale(image) + clahe = _apply_clahe(gray) + mask = breast_mask(clahe) + masked = cv2.bitwise_and(clahe, clahe, mask=mask) + no_pec = _remove_pectoral(masked, mask) + oriented = _normalize_orientation(no_pec, mask) + resized = cv2.resize(oriented, target_size, interpolation=cv2.INTER_AREA) + normalized = resized.astype(np.float32) / 255.0 + return normalized[:, :, np.newaxis] # (H, W, 1) + + +def load_image(path: str | Path) -> np.ndarray: + """Load a mammogram image from disk into a numpy array. + + Preserves the source bit depth (8- or 16-bit) so downstream CLAHE can + enhance from the full dynamic range. CBIS-DDSM PNGs converted from + DICOM are often 16-bit grayscale. + """ + file_path = Path(path) + if not file_path.exists(): + raise FileNotFoundError(f"Image not found: {file_path}") + image = cv2.imread(str(file_path), cv2.IMREAD_UNCHANGED) + if image is None: + raise ValueError(f"Failed to decode image (corrupt or unsupported format): {file_path}") + return image + + +def load_and_preprocess(path: str | Path, target_size: tuple = DEFAULT_SIZE) -> np.ndarray: + """Convenience: load an image file and run the full preprocessing pipeline.""" + return preprocess(load_image(path), target_size=target_size) + + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + +def _apply_clahe(gray: np.ndarray) -> np.ndarray: + """Apply CLAHE on the native bit depth, then normalize to uint8. + + CLAHE runs on the original depth (OpenCV handles uint16 natively) so the + contrast step uses the full dynamic range of DICOM-converted PNGs before + any quantization. Only after enhancement do we normalize down to uint8. + + The CLAHE object is created per call rather than shared at module level: + OpenCV's CLAHE instances hold internal buffers and are NOT thread-safe, and + preprocessing runs in parallel (tf.data num_parallel_calls). A shared + instance would produce subtle, hard-to-reproduce corruption under + concurrency. Per-call construction is cheap relative to the pipeline. + """ + clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8)) + enhanced = clahe.apply(gray) + if enhanced.dtype != np.uint8: + enhanced = cv2.normalize(enhanced, None, 0, 255, cv2.NORM_MINMAX).astype(np.uint8) + return enhanced + + +def _remove_pectoral(gray: np.ndarray, mask: np.ndarray) -> np.ndarray: + """Suppress the pectoral muscle region in MLO views. + + Strategy: the pectoral muscle is a bright triangle in the upper corner. + We detect it via edge-based line fitting and zero out that region. + This is a heuristic — it degrades gracefully (does nothing) on CC views + where no pectoral triangle is present. + """ + h, w = gray.shape + + # Only attempt removal in the upper 40% of the image where pectoral appears. + roi_h = int(h * 0.4) + roi = gray[:roi_h, :] + + edges = cv2.Canny(roi, 30, 100) + lines = cv2.HoughLinesP( + edges, rho=1, theta=np.pi / 180, + threshold=50, minLineLength=roi_h // 4, maxLineGap=20 + ) + + result = gray.copy() + if lines is None: + return result + + # Find the line most consistent with a pectoral edge (steep diagonal). + pectoral_line = _find_pectoral_line(lines, roi_h, w) + if pectoral_line is None: + return result + + # Build a mask for the pectoral triangle and zero it out. + x1, y1, x2, y2 = pectoral_line + pec_mask = np.zeros((h, w), dtype=np.uint8) + pts = np.array([[0, 0], [x2, y2], [x1, y1], [0, y1]], dtype=np.int32) + cv2.fillPoly(pec_mask, [pts], 255) + + # False-positive guard: a real pectoral triangle is a corner wedge. If the + # detected region covers an implausibly large fraction of the image, the + # line is more likely an artifact (e.g. an MLO scan line) and zeroing it + # would silently destroy real tissue. Skip removal rather than risk that. + pec_fraction = (pec_mask > 0).sum() / pec_mask.size + if pec_fraction > 0.25: + return result + + result[pec_mask > 0] = 0 + return result + + +def _find_pectoral_line(lines, roi_h: int, w: int): + """Select the line most likely to be the pectoral muscle edge. + + Criteria: steep negative slope (upper-left to lower-right), starts near + the top edge, and spans a meaningful length. + """ + best = None + best_score = -1 + + for line in lines: + x1, y1, x2, y2 = line[0] + dx = x2 - x1 + dy = y2 - y1 + length = np.hypot(dx, dy) + + if dx == 0: + continue + slope = dy / dx + + # Pectoral edge has a positive slope (going down-right from top-left). + if slope <= 0.3 or slope > 5.0: + continue + + # Should start near the top of the image. + top_y = min(y1, y2) + if top_y > roi_h * 0.3: + continue + + score = length + if score > best_score: + best_score = score + best = (x1, y1, x2, y2) + + return best + + +def _normalize_orientation(gray: np.ndarray, mask: np.ndarray) -> np.ndarray: + """Flip the image so the breast always faces right. + + Strategy: find the centroid of the breast mask. If it's in the left half, + flip horizontally. A fixed reference frame helps downstream models that + rely on positional embeddings. + """ + moments = cv2.moments(mask) + if moments["m00"] == 0: + return gray # Cannot determine orientation — leave as-is. + + cx = moments["m10"] / moments["m00"] + if cx < gray.shape[1] / 2: + return cv2.flip(gray, 1) + return gray diff --git a/frontend/android/app/src/main/python/second_look_bridge.py b/frontend/android/app/src/main/python/second_look_bridge.py new file mode 100644 index 0000000..53351d0 --- /dev/null +++ b/frontend/android/app/src/main/python/second_look_bridge.py @@ -0,0 +1,67 @@ +"""Kotlin-facing entry points for the Second Look Python pipeline. + +The app runs the *actual* research preprocessing code on-device via Chaquopy, +rather than a hand-ported copy, so what the model sees at inference time is +byte-for-byte what it saw during training. Everything below is a thin adapter: +no image logic lives here. + +Chaquopy type mapping used at this boundary: + Java String <-> Python str + Python bytes -> Java byte[] (via PyObject.toJava(byte[]::class)) + Python float -> Java double +""" + +import cv2 +import numpy as np + +from config.constants import INPUT_SIZE +from data_pipeline.label_mapper import confidence_to_tier +from data_pipeline.preprocessor import load_image, preprocess + +# Degrees -> the cv2 rotate code that undoes them. Android hands us the EXIF +# rotation because cv2.imread(IMREAD_UNCHANGED) deliberately ignores EXIF, and +# a sideways mammogram would break the pectoral/orientation heuristics. +_ROTATIONS = { + 90: cv2.ROTATE_90_CLOCKWISE, + 180: cv2.ROTATE_180, + 270: cv2.ROTATE_90_COUNTERCLOCKWISE, +} + + +def input_size() -> int: + """Model input edge length, from the shared config (INPUT_SIZE is square).""" + return int(INPUT_SIZE[0]) + + +def preprocess_file(path: str, rotation: int = 0) -> bytes: + """Preprocess an image file into the model's input tensor. + + Args: + path: Absolute path to the image the user picked or captured. + rotation: Clockwise EXIF rotation in degrees (0, 90, 180 or 270). + + Returns: + Raw little-endian float32 bytes for a [1, INPUT_SIZE, INPUT_SIZE, 1] + tensor, values in [0, 1]. + + Raises: + FileNotFoundError / ValueError: propagated from the pipeline when the + image is missing, corrupt, or in an unsupported format. + """ + image = load_image(path) + + rotate_code = _ROTATIONS.get(rotation % 360) + if rotate_code is not None: + image = cv2.rotate(image, rotate_code) + + tensor = preprocess(image, target_size=INPUT_SIZE) + return np.ascontiguousarray(tensor, dtype=" str: + """Concern tier for a positive-class probability ('Low'/'Moderate'/'Elevated'). + + Delegates to the shared cut-points so the app never carries its own copy of + them — they are provisional and will change when calibration lands. + """ + return confidence_to_tier(float(probability)) diff --git a/frontend/android/app/src/main/res/drawable-nodpi/ic_launcher_logo.png b/frontend/android/app/src/main/res/drawable-nodpi/ic_launcher_logo.png new file mode 100644 index 0000000..33a4e15 Binary files /dev/null and b/frontend/android/app/src/main/res/drawable-nodpi/ic_launcher_logo.png differ diff --git a/frontend/android/app/src/main/res/drawable/ic_add_photo.xml b/frontend/android/app/src/main/res/drawable/ic_add_photo.xml new file mode 100644 index 0000000..1b5be32 --- /dev/null +++ b/frontend/android/app/src/main/res/drawable/ic_add_photo.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/android/app/src/main/res/drawable/ic_camera.xml b/frontend/android/app/src/main/res/drawable/ic_camera.xml new file mode 100644 index 0000000..2cfbef6 --- /dev/null +++ b/frontend/android/app/src/main/res/drawable/ic_camera.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/android/app/src/main/res/drawable/ic_help.xml b/frontend/android/app/src/main/res/drawable/ic_help.xml new file mode 100644 index 0000000..e57390b --- /dev/null +++ b/frontend/android/app/src/main/res/drawable/ic_help.xml @@ -0,0 +1,20 @@ + + + + + + + diff --git a/frontend/android/app/src/main/res/drawable/ic_info.xml b/frontend/android/app/src/main/res/drawable/ic_info.xml new file mode 100644 index 0000000..3079973 --- /dev/null +++ b/frontend/android/app/src/main/res/drawable/ic_info.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/android/app/src/main/res/drawable/ic_launcher_background.xml b/frontend/android/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..5dfe58d --- /dev/null +++ b/frontend/android/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/android/app/src/main/res/drawable/ic_launcher_foreground.xml b/frontend/android/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..3bd80ce --- /dev/null +++ b/frontend/android/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,5 @@ + + + diff --git a/frontend/android/app/src/main/res/drawable/ic_launcher_monochrome.xml b/frontend/android/app/src/main/res/drawable/ic_launcher_monochrome.xml new file mode 100644 index 0000000..a64c767 --- /dev/null +++ b/frontend/android/app/src/main/res/drawable/ic_launcher_monochrome.xml @@ -0,0 +1,6 @@ + + + diff --git a/frontend/android/app/src/main/res/drawable/ic_lock.xml b/frontend/android/app/src/main/res/drawable/ic_lock.xml new file mode 100644 index 0000000..aa21d61 --- /dev/null +++ b/frontend/android/app/src/main/res/drawable/ic_lock.xml @@ -0,0 +1,17 @@ + + + + + + diff --git a/frontend/android/app/src/main/res/drawable/ic_medical_case.xml b/frontend/android/app/src/main/res/drawable/ic_medical_case.xml new file mode 100644 index 0000000..8c42765 --- /dev/null +++ b/frontend/android/app/src/main/res/drawable/ic_medical_case.xml @@ -0,0 +1,16 @@ + + + + + + diff --git a/frontend/android/app/src/main/res/drawable/ic_photo_library.xml b/frontend/android/app/src/main/res/drawable/ic_photo_library.xml new file mode 100644 index 0000000..7bcf4fc --- /dev/null +++ b/frontend/android/app/src/main/res/drawable/ic_photo_library.xml @@ -0,0 +1,15 @@ + + + + + + diff --git a/frontend/android/app/src/main/res/drawable/ic_pulse_search.xml b/frontend/android/app/src/main/res/drawable/ic_pulse_search.xml new file mode 100644 index 0000000..e19a918 --- /dev/null +++ b/frontend/android/app/src/main/res/drawable/ic_pulse_search.xml @@ -0,0 +1,24 @@ + + + + + + + diff --git a/frontend/android/app/src/main/res/drawable/ic_refresh.xml b/frontend/android/app/src/main/res/drawable/ic_refresh.xml new file mode 100644 index 0000000..e72c43d --- /dev/null +++ b/frontend/android/app/src/main/res/drawable/ic_refresh.xml @@ -0,0 +1,16 @@ + + + + + + diff --git a/frontend/android/app/src/main/res/drawable/ic_shield_alert.xml b/frontend/android/app/src/main/res/drawable/ic_shield_alert.xml new file mode 100644 index 0000000..51f5add --- /dev/null +++ b/frontend/android/app/src/main/res/drawable/ic_shield_alert.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/android/app/src/main/res/drawable/ic_shield_check.xml b/frontend/android/app/src/main/res/drawable/ic_shield_check.xml new file mode 100644 index 0000000..9058d82 --- /dev/null +++ b/frontend/android/app/src/main/res/drawable/ic_shield_check.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/android/app/src/main/res/drawable/ic_stethoscope.xml b/frontend/android/app/src/main/res/drawable/ic_stethoscope.xml new file mode 100644 index 0000000..1b8b58e --- /dev/null +++ b/frontend/android/app/src/main/res/drawable/ic_stethoscope.xml @@ -0,0 +1,24 @@ + + + + + + + + diff --git a/frontend/android/app/src/main/res/drawable/ic_warning.xml b/frontend/android/app/src/main/res/drawable/ic_warning.xml new file mode 100644 index 0000000..69052ec --- /dev/null +++ b/frontend/android/app/src/main/res/drawable/ic_warning.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/frontend/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..8fde456 --- /dev/null +++ b/frontend/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/frontend/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..8fde456 --- /dev/null +++ b/frontend/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/frontend/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..8be9b72 Binary files /dev/null and b/frontend/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/frontend/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/frontend/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..8be9b72 Binary files /dev/null and b/frontend/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/frontend/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/frontend/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..1139715 Binary files /dev/null and b/frontend/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/frontend/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/frontend/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..1139715 Binary files /dev/null and b/frontend/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/frontend/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/frontend/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..5e7db53 Binary files /dev/null and b/frontend/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/frontend/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/frontend/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..5e7db53 Binary files /dev/null and b/frontend/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/frontend/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/frontend/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..fdcd96a Binary files /dev/null and b/frontend/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/frontend/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/frontend/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..fdcd96a Binary files /dev/null and b/frontend/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/frontend/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/frontend/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..80cf940 Binary files /dev/null and b/frontend/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/frontend/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/frontend/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..80cf940 Binary files /dev/null and b/frontend/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/frontend/android/app/src/main/res/values-night/colors.xml b/frontend/android/app/src/main/res/values-night/colors.xml new file mode 100644 index 0000000..c7c6bff --- /dev/null +++ b/frontend/android/app/src/main/res/values-night/colors.xml @@ -0,0 +1,5 @@ + + + + #000000 + diff --git a/frontend/android/app/src/main/res/values-night/themes.xml b/frontend/android/app/src/main/res/values-night/themes.xml new file mode 100644 index 0000000..2bc268c --- /dev/null +++ b/frontend/android/app/src/main/res/values-night/themes.xml @@ -0,0 +1,8 @@ + + + + + + diff --git a/frontend/android/app/src/main/res/values/colors.xml b/frontend/android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..8511482 --- /dev/null +++ b/frontend/android/app/src/main/res/values/colors.xml @@ -0,0 +1,6 @@ + + + + #FFFFFF + diff --git a/frontend/android/app/src/main/res/values/strings.xml b/frontend/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..b8d1c10 --- /dev/null +++ b/frontend/android/app/src/main/res/values/strings.xml @@ -0,0 +1,36 @@ + + SecondLook + + + Second Look + This app provides a non-diagnostic, informational review of mammogram images. It does not replace professional medical evaluation. All analysis runs on-device — no images are stored or transmitted. + I understand this is not a medical diagnosis, and I will consult a healthcare professional for any concerns. + Continue + + + Add an Image + Choose a mammogram image from your library, or take a photo. + Choose Image + Take Photo + + + Analyzing on-device… + Nothing leaves your phone + + + Concern Tier + Low Concern + Moderate — Worth a Second Look + Elevated — Worth a Second Look + Analysis Unavailable + %1$d%% + What this means + This is not a diagnosis. The tier reflects patterns the model found notable, not confirmed findings. + The analysis could not be completed. Please try again with a different image. + Next step + Please consult a healthcare professional for any concerns. + Scan Another Image + + + The mammogram image you selected + diff --git a/frontend/android/app/src/main/res/values/themes.xml b/frontend/android/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..5281286 --- /dev/null +++ b/frontend/android/app/src/main/res/values/themes.xml @@ -0,0 +1,7 @@ + + + + + diff --git a/frontend/android/app/src/main/res/xml/backup_rules.xml b/frontend/android/app/src/main/res/xml/backup_rules.xml new file mode 100644 index 0000000..4df9255 --- /dev/null +++ b/frontend/android/app/src/main/res/xml/backup_rules.xml @@ -0,0 +1,13 @@ + + + + \ No newline at end of file diff --git a/frontend/android/app/src/main/res/xml/data_extraction_rules.xml b/frontend/android/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 0000000..9ee9997 --- /dev/null +++ b/frontend/android/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,19 @@ + + + + + + + \ No newline at end of file diff --git a/frontend/android/app/src/main/res/xml/file_paths.xml b/frontend/android/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..d6de9dd --- /dev/null +++ b/frontend/android/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,7 @@ + + + + + diff --git a/frontend/android/build.gradle.kts b/frontend/android/build.gradle.kts new file mode 100644 index 0000000..338d2a9 --- /dev/null +++ b/frontend/android/build.gradle.kts @@ -0,0 +1,6 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. +plugins { + alias(libs.plugins.android.application) apply false + alias(libs.plugins.kotlin.compose) apply false + alias(libs.plugins.chaquopy) apply false +} diff --git a/frontend/android/gradle.properties b/frontend/android/gradle.properties new file mode 100644 index 0000000..452df67 --- /dev/null +++ b/frontend/android/gradle.properties @@ -0,0 +1,20 @@ +# Project-wide Gradle settings. +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. For more details, visit +# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects +# org.gradle.parallel=true +# The Configuration Cache is off because Chaquopy is not compatible with it: it +# shells out to buildPython during the configuration phase, which the cache +# forbids ("Starting an external process ... during configuration time is +# unsupported"). Re-enable it if Chaquopy ever gains support. +org.gradle.configuration-cache=false +# Kotlin code style for this project: "official" or "obsolete": +kotlin.code.style=official \ No newline at end of file diff --git a/frontend/android/gradle/gradle-daemon-jvm.properties b/frontend/android/gradle/gradle-daemon-jvm.properties new file mode 100644 index 0000000..6c1139e --- /dev/null +++ b/frontend/android/gradle/gradle-daemon-jvm.properties @@ -0,0 +1,12 @@ +#This file is generated by updateDaemonJvm +toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect +toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect +toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect +toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect +toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/73bcfb608d1fde9fb62e462f834a3299/redirect +toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/846ee0d876d26a26f37aa1ce8de73224/redirect +toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect +toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect +toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/9482ddec596298c84656d31d16652665/redirect +toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/39701d92e1756bb2f141eb67cd4c660e/redirect +toolchainVersion=21 diff --git a/frontend/android/gradle/libs.versions.toml b/frontend/android/gradle/libs.versions.toml new file mode 100644 index 0000000..396594e --- /dev/null +++ b/frontend/android/gradle/libs.versions.toml @@ -0,0 +1,38 @@ +[versions] +# Pinned to 9.2.x: Chaquopy 17 supports AGP 7.3 – 9.2, and the on-device +# Python preprocessing pipeline depends on Chaquopy. +agp = "9.2.1" +coreKtx = "1.10.1" +junit = "4.13.2" +junitVersion = "1.1.5" +espressoCore = "3.5.1" +lifecycleRuntimeKtx = "2.6.1" +activityCompose = "1.12.4" +kotlin = "2.2.10" +composeBom = "2026.02.01" +chaquopy = "17.0.0" +tensorflowLite = "2.17.0" +exifinterface = "1.4.2" + +[libraries] +androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } +junit = { group = "junit", name = "junit", version.ref = "junit" } +androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } +androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } +androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } +androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } +androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } +androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" } +androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } +androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } +androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } +androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } +androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } +androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" } +androidx-exifinterface = { group = "androidx.exifinterface", name = "exifinterface", version.ref = "exifinterface" } +tensorflow-lite = { group = "org.tensorflow", name = "tensorflow-lite", version.ref = "tensorflowLite" } + +[plugins] +android-application = { id = "com.android.application", version.ref = "agp" } +kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +chaquopy = { id = "com.chaquo.python", version.ref = "chaquopy" } diff --git a/frontend/android/gradle/wrapper/gradle-wrapper.jar b/frontend/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..8bdaf60 Binary files /dev/null and b/frontend/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/frontend/android/gradle/wrapper/gradle-wrapper.properties b/frontend/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..7bf3390 --- /dev/null +++ b/frontend/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,9 @@ +#Mon Jul 27 13:41:31 UZT 2026 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionSha256Sum=553c78f50dafcd54d65b9a444649057857469edf836431389695608536d6b746 +distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.0-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/frontend/android/gradlew b/frontend/android/gradlew new file mode 100755 index 0000000..ef07e01 --- /dev/null +++ b/frontend/android/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/frontend/android/gradlew.bat b/frontend/android/gradlew.bat new file mode 100644 index 0000000..5eed7ee --- /dev/null +++ b/frontend/android/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/frontend/android/settings.gradle.kts b/frontend/android/settings.gradle.kts new file mode 100644 index 0000000..ffe699f --- /dev/null +++ b/frontend/android/settings.gradle.kts @@ -0,0 +1,26 @@ +pluginManagement { + repositories { + google { + content { + includeGroupByRegex("com\\.android.*") + includeGroupByRegex("com\\.google.*") + includeGroupByRegex("androidx.*") + } + } + mavenCentral() + gradlePluginPortal() + } +} +plugins { + id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" +} +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "SecondLook" +include(":app")