Skip to content

Commit

Permalink
[154] Differentiate masked images (#65)
Browse files Browse the repository at this point in the history
* [154] Differentiaties masked images

* [154] Replaces magic number

* Fixes MaxLineLength in SampleImagesAdapter

* [154] Modifies blood sample views to add masked dot

* [154] Checks if mask bitmap is empty

* [154] Removes unnecssary layout for masked sample

* Fixes NewLineAtEndOfFile debt

* [154] Uses View Binding in SampleImagesAdapter

* [154] Removes unnecessary contraint layout

* Updates recycler view

Co-authored-by: Eduard-Cristian Boloș <[email protected]>

* [154] Updates sample images layouts

* Uses isVisible instead of visibility for dot image

Co-authored-by: Eduard-Cristian Boloș <[email protected]>

* Adds isVisible import

* [154] Moves binding to ImageViewHolder

* [154] Display mask over blood samples in metadata screen

* [154] Improve captured images modeling

* [154] Fix spacing between sample images

* [154] Add missing top margin on metadata screen

* [154] Rename Images to Captures

* [154] Make it clearer how adding masks to captures works

* [154] Remove unnecessary semicolon

* [154] Remove resolved TODO

Co-authored-by: Clara Gaset <[email protected]>
Co-authored-by: Clara Gaset <[email protected]>
  • Loading branch information
3 people authored Oct 3, 2020
1 parent f66e7bf commit 20117b4
Show file tree
Hide file tree
Showing 25 changed files with 475 additions and 208 deletions.
1 change: 1 addition & 0 deletions app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ dependencies {

// Test
testImplementation 'junit:junit:4.13'
testImplementation 'com.nitorcreations:matchers:1.3'
testImplementation "org.mockito:mockito-core:$mockito_version"
testImplementation "org.mockito:mockito-inline:$mockito_version"
testImplementation 'com.nhaarman:mockito-kotlin:1.6.0'
Expand Down
12 changes: 0 additions & 12 deletions app/src/main/java/net/aiscope/gdd_app/extensions/Collections.kt

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package net.aiscope.gdd_app.extensions


fun <T> List<T>.replaceElementAt(index: Int, value: T) =
this.slice(0 until index) + value + this.slice(index + 1 until this.size)
61 changes: 53 additions & 8 deletions app/src/main/java/net/aiscope/gdd_app/model/SampleModels.kt
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
package net.aiscope.gdd_app.model

import net.aiscope.gdd_app.extensions.plus
import net.aiscope.gdd_app.extensions.replaceElementAt
import java.io.File
import java.util.Calendar
import java.util.LinkedHashSet

data class Sample(
val id: String,
Expand All @@ -12,20 +11,19 @@ data class Sample(
val disease: String,
val preparation: SamplePreparation? = null,
val microscopeQuality: MicroscopeQuality? = null,
val images: LinkedHashSet<File> = linkedSetOf(),
val masks: LinkedHashSet<File> = linkedSetOf(),
val captures: Captures = Captures(),
val metadata: SampleMetadata = SampleMetadata(),
val status: SampleStatus = SampleStatus.Incomplete,
val createdOn: Calendar = Calendar.getInstance(),
val lastModified: Calendar = Calendar.getInstance()
) {
fun addImage(path: File) = copy(images = images + path)
fun addNewlyCapturedImage(path: File) = copy(captures = captures.newCapture(path))

fun addMask(path: File) = copy(masks = masks + path)
fun upsertMask(path: File, isEmpty: Boolean) = copy(captures = captures.upsertMask(path, isEmpty))

fun nextImageName(): String = "${id}_image_${images.size}"
fun nextImageName(): String = "${id}_image_${captures.completedCaptureCount()}"

fun nextMaskName(): String = "${id}_mask_${images.size}"
fun nextMaskName(): String = "${id}_mask_${captures.completedCaptureCount()}"
}

enum class SampleStatus(val id: Short) {
Expand Down Expand Up @@ -56,6 +54,53 @@ data class MicroscopeQuality(
val magnification: Int
)

data class Captures(
val inProgressCapture: InProgressCapture?,
val completedCaptures: List<CompletedCapture>
) {

constructor() : this(null, emptyList())

fun newCapture(path: File): Captures = Captures(InProgressCapture(path), completedCaptures)

fun upsertMask(maskPath: File, isEmpty: Boolean): Captures {
val indexOfExistingCaptureForMask = completedCaptures.indexOfFirst { it.mask == maskPath }

return when {
indexOfExistingCaptureForMask >= 0 && inProgressCapture != null ->
// we somehow broke the business logic
throw IllegalStateException(
"Trying to add mask $maskPath at index $indexOfExistingCaptureForMask " +
"when inProgressCapture is not null ($inProgressCapture)"
)
inProgressCapture != null -> // indexOfCaptureIfMaskExists < 0
Captures(
null,
completedCaptures + CompletedCapture(
inProgressCapture.image, maskPath, isEmpty
)
)
else -> // inProgressCapture == null && indexOfCaptureIfMaskExists < 0
Captures(
null,
completedCaptures.replaceElementAt(
indexOfExistingCaptureForMask,
completedCaptures[indexOfExistingCaptureForMask].copy(maskIsEmpty = isEmpty)
)
)
}
}

fun completedCaptureCount(): Int {
return completedCaptures.size
}

}

sealed class Capture
data class InProgressCapture(val image: File) : Capture()
data class CompletedCapture(val image: File, val mask: File, val maskIsEmpty: Boolean) : Capture()

data class SampleMetadata(
val smearType: SmearType = SmearType.THIN,
val species: MalariaSpecies = MalariaSpecies.P_FALCIPARUM,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,9 @@ class FirebaseRemoteStorage(private val uploader: FirebaseStorageUploader, priva

uploader.upload(gson.toJson(sample.toDto()), jsonKey)

sample.images.forEachIndexed { index, image ->
uploader.upload(image, "${sample.id}/image_${index}.jpg")
}

sample.masks.forEachIndexed { index, mask ->
uploader.upload(mask, "${sample.id}/mask_${index}.png")
sample.captures.completedCaptures.forEachIndexed { index, capture ->
uploader.upload(capture.image, "${sample.id}/image_${index}.jpg")
uploader.upload(capture.mask, "${sample.id}/mask_${index}.png")
}
}
}
57 changes: 39 additions & 18 deletions app/src/main/java/net/aiscope/gdd_app/repository/SampleDtos.kt
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package net.aiscope.gdd_app.repository

import com.google.gson.annotations.SerializedName
import net.aiscope.gdd_app.extensions.toLinkedHashSet
import net.aiscope.gdd_app.model.CompletedCapture
import net.aiscope.gdd_app.model.Captures
import net.aiscope.gdd_app.model.InProgressCapture
import net.aiscope.gdd_app.model.MalariaSpecies
import net.aiscope.gdd_app.model.MicroscopeQuality
import net.aiscope.gdd_app.model.Sample
Expand All @@ -22,25 +24,42 @@ data class SampleDto(
@SerializedName("microscopeQuality") val microscopeQuality: MicroscopeQualityDto?,
@SerializedName("imagePaths") val imagePaths: List<String>,
@SerializedName("maskPaths") val maskPaths: List<String>,
@SerializedName("areMasksEmpty") val areMasksEmpty: List<Boolean>,
@SerializedName("metadata") val metadata: SampleMetadataDto,
@SerializedName("status") val status: Short,
@SerializedName("createdOn") val createdOn: Calendar = Calendar.getInstance(),
@SerializedName("lastModified") val lastModified : Calendar = Calendar.getInstance()
@SerializedName("lastModified") val lastModified: Calendar = Calendar.getInstance()
) {
fun toDomain(): Sample = Sample(
id = id,
healthFacility = healthFacility,
microscopist = microscopist,
disease = disease,
preparation = preparation?.toDomain(),
microscopeQuality = microscopeQuality?.toDomain(),
images = imagePaths.map { File(it) }.toLinkedHashSet(),
masks = maskPaths.map { File(it) }.toLinkedHashSet(),
metadata = metadata.toDomain(),
status = SampleStatus.values().first { it.id == status },
createdOn = createdOn,
lastModified = lastModified
)
fun toDomain(): Sample {
val areMasksEmpty = backfillAreMaskEmpty()
val completedCaptures = buildCompletedCaptures(areMasksEmpty)
val inProgressCapture = extractInProgressCapture()

return Sample(
id = id,
healthFacility = healthFacility,
microscopist = microscopist,
disease = disease,
preparation = preparation?.toDomain(),
microscopeQuality = microscopeQuality?.toDomain(),
captures = Captures(inProgressCapture, completedCaptures),
metadata = metadata.toDomain(),
status = SampleStatus.values().first { it.id == status },
createdOn = createdOn,
lastModified = lastModified
)
}

private fun backfillAreMaskEmpty() =
if (areMasksEmpty.isNotEmpty()) areMasksEmpty else List(imagePaths.size) { false }

private fun buildCompletedCaptures(areMasksEmpty: List<Boolean>) =
imagePaths.zip(maskPaths).zip(areMasksEmpty) { filesPair, emptyMask ->
CompletedCapture(File(filesPair.first), File(filesPair.second), emptyMask)
}

private fun extractInProgressCapture() =
if (imagePaths.size > maskPaths.size) InProgressCapture(File(imagePaths.last())) else null
}

data class SamplePreparationDto(
Expand Down Expand Up @@ -91,8 +110,10 @@ fun Sample.toDto() = SampleDto(
disease = disease,
preparation = preparation?.toDto(),
microscopeQuality = microscopeQuality?.toDto(),
imagePaths = images.map { it.absolutePath },
maskPaths = masks.map { it.absolutePath },
imagePaths = captures.completedCaptures.map { it.image.absolutePath } +
listOfNotNull(captures.inProgressCapture?.image?.absolutePath),
maskPaths = captures.completedCaptures.map { it.mask.absolutePath },
areMasksEmpty = captures.completedCaptures.map { it.maskIsEmpty },
metadata = metadata.toDto(),
status = status.id,
createdOn = createdOn,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ class CaptureImagePresenter(
if (file == null) {
view.notifyImageCouldNotBeTaken()
} else {
val sample = repository.current().addImage(file)
val sample = repository.current().addNewlyCapturedImage(file)
repository.store(sample)

view.goToMask(sample.disease, file.absolutePath, sample.nextMaskName())
Expand Down
11 changes: 10 additions & 1 deletion app/src/main/java/net/aiscope/gdd_app/ui/mask/MaskActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,9 @@ class MaskActivity : AppCompatActivity(), MaskView, CaptureFlow {

stagesBtn.setOnClickListener { selectStagePopup.show() }

getBitmapBtn.setOnClickListener { presenter.handleCaptureBitmap(maskNameExtra) }
getBitmapBtn.setOnClickListener {
presenter.handleCaptureBitmap(maskNameExtra, isEmptyMaskBitmap())
}

photoMaskView.onMaskingActionFinishedListener = View.OnTouchListener { _, _ ->
setEnabled(undoBtn, photoMaskView.undoAvailable())
Expand Down Expand Up @@ -190,4 +192,11 @@ class MaskActivity : AppCompatActivity(), MaskView, CaptureFlow {
mutable = mutable
)
}

private fun isEmptyMaskBitmap(): Boolean {
val maskBitmap = binding.photoMaskView.getMaskBitmap()
val emptyBitmap: Bitmap =
Bitmap.createBitmap(maskBitmap.width, maskBitmap.height, maskBitmap.config)
return maskBitmap.sameAs(emptyBitmap)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,12 @@ class MaskPresenter(
)
}

fun handleCaptureBitmap(maskName: String) {
fun handleCaptureBitmap(maskName: String, isEmptyMask: Boolean) {
view.takeMask(maskName) { file ->
if (file == null) {
view.notifyImageCouldNotBeTaken()
} else {
val sample = repository.current().addMask(file)
val sample = repository.current().upsertMask(file, isEmptyMask)
repository.store(sample)

view.goToMetadata()
Expand All @@ -38,7 +38,6 @@ class MaskPresenter(
}

companion object {

private fun composeBrushDiseaseStagesArray(
diseaseName: String,
resources: Resources
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package net.aiscope.gdd_app.ui.metadata
import android.content.Intent
import android.os.Bundle
import android.view.View
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
Expand All @@ -13,6 +12,7 @@ import kotlinx.coroutines.launch
import net.aiscope.gdd_app.R
import net.aiscope.gdd_app.databinding.ActivityMetadataBinding
import net.aiscope.gdd_app.extensions.select
import net.aiscope.gdd_app.model.CompletedCapture
import net.aiscope.gdd_app.ui.CaptureFlow
import net.aiscope.gdd_app.ui.attachCaptureFlowToolbar
import net.aiscope.gdd_app.ui.capture.CaptureImageActivity
Expand All @@ -21,7 +21,6 @@ import net.aiscope.gdd_app.ui.mask.MaskActivity
import net.aiscope.gdd_app.ui.showConfirmExitDialog
import net.aiscope.gdd_app.ui.snackbar.CustomSnackbar
import net.aiscope.gdd_app.ui.snackbar.CustomSnackbarAction
import java.io.File
import javax.inject.Inject

@Suppress("TooManyFunctions")
Expand Down Expand Up @@ -69,8 +68,7 @@ class MetadataActivity : AppCompatActivity(), MetadataView, CaptureFlow {
}

override fun fillForm(model: ViewStateModel) {
imagesAdapter.setImages(model.images)
imagesAdapter.setMasks(model.masks)
imagesAdapter.setCaptures(model.captures)
model.smearTypeId?.let { binding.metadataSectionSmearTypeRadioGroup.check(it) }
model.speciesValue?.let { binding.metadataSpeciesSpinner.select(it) }
}
Expand All @@ -96,15 +94,15 @@ class MetadataActivity : AppCompatActivity(), MetadataView, CaptureFlow {
this.startActivity(intent)
}

override fun editImage(disease: String, image: File, mask: File){
override fun editCapture(disease: String, capture: CompletedCapture){
val intent = Intent(this, MaskActivity::class.java)
intent.putExtra(MaskActivity.EXTRA_DISEASE_NAME, disease)
intent.putExtra(MaskActivity.EXTRA_IMAGE_NAME, image.absolutePath)
intent.putExtra(MaskActivity.EXTRA_IMAGE_NAME, capture.image.absolutePath)

//Remove file extension
val maskName = mask.name.removeSuffix(".png")
val maskName = capture.mask.name.removeSuffix(".png")
intent.putExtra(MaskActivity.EXTRA_MASK_NAME, maskName)
intent.putExtra(MaskActivity.EXTRA_MASK_PATH, mask.path)
intent.putExtra(MaskActivity.EXTRA_MASK_PATH, capture.mask.path)

startActivity(intent)
}
Expand All @@ -129,9 +127,9 @@ class MetadataActivity : AppCompatActivity(), MetadataView, CaptureFlow {
}
}

private fun onImageClicked(image: File, mask: File) {
private fun onImageClicked(capture: CompletedCapture) {
lifecycleScope.launch {
presenter.editImage(image, mask)
presenter.editImage(capture)
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,19 +1,18 @@
package net.aiscope.gdd_app.ui.metadata

import android.content.Context
import net.aiscope.gdd_app.model.CompletedCapture
import net.aiscope.gdd_app.model.SampleMetadata
import net.aiscope.gdd_app.model.SampleStatus
import net.aiscope.gdd_app.network.RemoteStorage
import net.aiscope.gdd_app.repository.SampleRepository
import timber.log.Timber
import java.io.File
import javax.inject.Inject

data class FieldOption(val id: Long, val title: Int)
data class ViewStateModel(
val disease: String,
val images: List<File>,
val masks: List<File>,
val captures: List<CompletedCapture>,
val options: List<FieldOption>,
val required: Boolean = true,
val smearTypeId: Int? = null,
Expand All @@ -36,8 +35,7 @@ class MetadataPresenter @Inject constructor(
view.fillForm(
ViewStateModel(
sample.disease,
sample.images.toList(),
sample.masks.toList(),
sample.captures.completedCaptures,
emptyList(),
smearTypeId = lastMetadata?.let { metadataMapper.getSmearTypeId(it.smearType) },
speciesValue = lastMetadata?.let { metadataMapper.getSpeciesValue(context, it.species) },
Expand Down Expand Up @@ -71,8 +69,8 @@ class MetadataPresenter @Inject constructor(
view.captureImage(current.nextImageName())
}

suspend fun editImage(image: File, mask: File) {
suspend fun editImage(capture: CompletedCapture) {
val current = repository.current()
view.editImage(current.disease, image, mask)
view.editCapture(current.disease, capture)
}
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
package net.aiscope.gdd_app.ui.metadata

import java.io.File
import net.aiscope.gdd_app.model.CompletedCapture

interface MetadataView {
fun fillForm(model: ViewStateModel)
fun captureImage(nextImageName: String)
fun editImage(disease: String, image: File, mask: File)
fun editCapture(disease: String, capture: CompletedCapture)
fun finishFlow()
fun showRetryBar()
}
Loading

0 comments on commit 20117b4

Please sign in to comment.