Skip to content

ADFA-3290 | Implement smart boundary detection for dynamic margins#1170

Closed
jatezzz wants to merge 1 commit intostagefrom
feat/ADFA-3290-smart-boundary-detection
Closed

ADFA-3290 | Implement smart boundary detection for dynamic margins#1170
jatezzz wants to merge 1 commit intostagefrom
feat/ADFA-3290-smart-boundary-detection

Conversation

@jatezzz
Copy link
Copy Markdown
Collaborator

@jatezzz jatezzz commented Apr 9, 2026

Description

This PR replaces the hardcoded margin boundaries (0.2f and 0.8f) with a smart algorithm that calculates their optimal positions dynamically. It introduces the SmartBoundaryDetector and a new calculateVerticalProjection utility in BitmapUtils to analyze image content and snap guidelines to the best available vertical gaps.

Details

  • Extracted pixel analysis logic into BitmapUtils.calculateVerticalProjection.
  • Created SmartBoundaryDetector to find gap midpoints using the projection data.
  • Updated ComputerVisionViewModel.loadImageFromUri to apply the dynamically calculated left and right percentage bounds instead of resetting to default hardcoded values.
document_5098099422705747202.mp4

Ticket

ADFA-3290

Observation

The edge detection approach in calculateVerticalProjection uses a simplified, highly performant Sobel-like technique by directly calculating pixel variations on the red channel, avoiding heavy matrix operations on the main thread.

Replaces hardcoded bounds with a vertical projection edge-detection algorithm.
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 9, 2026

📝 Walkthrough

Release Notes: Smart Boundary Detection for Dynamic Margins

Features

  • Dynamic margin boundary detection: Replaced hardcoded margin bounds (0.2f left, 0.8f right) with an intelligent algorithm that analyzes image content to determine optimal guideline positions
  • Vertical edge projection analysis: Added BitmapUtils.calculateVerticalProjection() that performs simplified Sobel-like edge detection on the red channel to identify content boundaries
  • Adaptive gap detection: Introduced SmartBoundaryDetector that identifies gaps between content regions and positions guidelines at gap midpoints with configurable zone-based search areas
  • Automatic fallback mechanism: Falls back to fixed percentile bounds (0.15f/0.85f) if dynamic detection fails to find sufficient gaps, ensuring guidelines are always positioned

Technical Changes

  • Updated ComputerVisionViewModel.loadImageFromUri() to compute dynamic left/right percentages from detected boundary pixel positions
  • Image processing moved to Dispatchers.Default thread to prevent main thread blocking
  • Added configurable parameters for edge detection sensitivity, zone boundaries, and fallback thresholds

⚠️ Risks & Best Practices Violations

Memory Pressure

  • calculateVerticalProjection() loads all bitmap pixels into a single IntArray(width * height) without size checks. Large images (e.g., 4K+) could cause out-of-memory exceptions. Consider implementing bitmap dimension validation or chunked processing for large images.

Performance Concerns

  • Pixel-by-pixel iteration through entire image for edge detection could be slow on high-resolution images. No caching of projection results if the same image is processed multiple times.
  • Single-channel edge detection (red channel only) is a performance trade-off that may miss edges in certain image types or color spaces (e.g., images with color-cast or unusual channel distributions).

Limited Error Handling

  • SmartBoundaryDetector.detectSmartBoundaries() lacks explicit error handling if the projection array is malformed or empty. While it handles null gap detection, edge cases with very small images (width < 3) return empty projections silently.
  • No validation that detected boundaries are sensible (e.g., leftBound < rightBound).

Hardcoded Fallback Values

  • Fallback percentiles (0.15f/0.85f) are still fixed values. If content naturally occupies a different range, guidelines may be mispositioned. Consider making fallback logic based on image characteristics.

Threshold Tunability

  • EDGE_DETECTION_THRESHOLD (30) is a magic number without documented justification for different image types or resolutions. May need adjustment for low-contrast or high-contrast images.

Testing Recommendations

  • Test with very large images (4K, 8K resolutions)
  • Test with images lacking clear content boundaries (blank, low-contrast, solid color)
  • Validate boundary detection across different color spaces and image types
  • Stress test memory usage on devices with limited RAM

Walkthrough

The changes implement smart boundary detection for image guides in the computer vision module. A new SmartBoundaryDetector analyzes vertical pixel projections to dynamically compute left and right boundary positions, replacing fixed percentages. ComputerVisionViewModel integrates this detector into the image-loading flow, processing bitmaps asynchronously with rotation and boundary computation before updating guide states.

Changes

Cohort / File(s) Summary
ViewModel Integration
ComputerVisionViewModel.kt
Refactored image loading to use withContext(Dispatchers.Default) for bitmap processing, rotate the bitmap, invoke SmartBoundaryDetector.detectSmartBoundaries() to compute left/right boundaries, and dynamically initialize guide percentages from detected boundaries instead of fixed values (0.2f/0.8f). Early return on null bitmap with toast message.
Boundary Detection Utilities
BitmapUtils.kt, SmartBoundaryDetector.kt
Added calculateVerticalProjection() utility function to analyze red-channel intensity transitions across bitmap columns. Introduced new SmartBoundaryDetector singleton with detectSmartBoundaries() that uses vertical projection analysis, gap detection, signal normalization, and fallback thresholding to robustly identify left and right content boundaries with configurable edge-ignore behavior.

Sequence Diagram(s)

sequenceDiagram
    actor User
    participant ViewModel as ComputerVisionViewModel
    participant Decoder as Bitmap Decoder
    participant Rotator as Bitmap Rotation
    participant BitmapUtils as BitmapUtils
    participant Detector as SmartBoundaryDetector

    User->>ViewModel: Select Image URI
    ViewModel->>Decoder: uriToBitmap(uri)
    Decoder-->>ViewModel: bitmap (or null)
    
    alt Bitmap Decoded Successfully
        ViewModel->>Rotator: Rotate bitmap to correct orientation
        Rotator-->>ViewModel: rotatedBitmap
        
        ViewModel->>BitmapUtils: calculateVerticalProjection(rotatedBitmap)
        BitmapUtils-->>ViewModel: FloatArray projection
        
        ViewModel->>Detector: detectSmartBoundaries(rotatedBitmap)
        Detector->>Detector: Analyze projection, detect gaps
        Detector->>Detector: Attempt primary detection
        Detector->>Detector: Apply fallbacks if needed
        Detector-->>ViewModel: Pair<leftBound, rightBound>
        
        ViewModel->>ViewModel: Convert bounds to guide percentages
        ViewModel->>ViewModel: Update guide state
    else Bitmap Decoding Failed
        ViewModel->>ViewModel: Show error toast
        ViewModel->>ViewModel: Return early
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested reviewers

  • Daniel-ADFA
  • itsaky-adfa

Poem

🐰 A smart boundary detector hops through pixels bright,
Analyzing projections with algorithmic might,
From blurry edges it finds the path so true,
Dynamic guides now replace the static few!
The vision sharpens—no more guesswork here.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title directly aligns with the main change: replacing hardcoded margin boundaries with smart boundary detection for dynamic margins.
Description check ✅ Passed The description clearly relates to the changeset, explaining the addition of SmartBoundaryDetector, calculateVerticalProjection utility, and updates to ComputerVisionViewModel.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/ADFA-3290-smart-boundary-detection

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/ui/viewmodel/ComputerVisionViewModel.kt (1)

143-146: ⚠️ Potential issue | 🟠 Major

Rethrow CancellationException in loadImageFromUri.

The broad catch (e: Exception) at lines 143–146 swallows CancellationException from viewModelScope.launch, breaking structured cancellation and preventing proper coroutine cancellation propagation.

🔧 Proposed fix
+import kotlinx.coroutines.CancellationException
 ...
-            } catch (e: Exception) {
+            } catch (e: CancellationException) {
+                throw e
+            } catch (e: Exception) {
                 Log.e(TAG, "Error loading image from URI", e)
                 _uiEffect.send(ComputerVisionEffect.ShowError("Failed to load image: ${e.message}"))
             }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/ui/viewmodel/ComputerVisionViewModel.kt`
around lines 143 - 146, In loadImageFromUri (the coroutine launched via
viewModelScope.launch) the catch (e: Exception) block swallows
CancellationException; change the error handling so CancellationException is
rethrown immediately (or check e is CancellationException and throw it) before
handling other exceptions so coroutine cancellation propagates correctly; leave
the existing Log.e(TAG, ...) and
_uiEffect.send(ComputerVisionEffect.ShowError(...)) for non-cancellation errors.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/utils/SmartBoundaryDetector.kt`:
- Around line 17-44: The detectSmartBoundaries function can pass invalid
start/end indices into projection.copyOfRange when edgeIgnorePercent is negative
or too large; before slicing the projection (and before using
leftZoneEnd/rightZoneStart/rightZoneEnd) clamp and validate computed indices:
compute ignoredEdgePixels = clamp((width * edgeIgnorePercent).toInt(), 0,
width), ensure leftZoneEnd = min((width * LEFT_ZONE_END_PERCENT).toInt(), width)
and rightZoneStart = max((width * RIGHT_ZONE_START_PERCENT).toInt(), 0), and
ensure rightZoneEnd = max(width - ignoredEdgePixels, rightZoneStart); if any
computed slice would be empty or inverted, skip findBestGapMidpoint calls and
fall back to LEFT_FALLBACK_BOUND_PERCENT/RIGHT_FALLBACK_BOUND_PERCENT (or call
findBestGapMidpoint with an empty-safe path like normalizeSignal=true) so
copyOfRange is never called with invalid ranges in detectSmartBoundaries while
keeping references to calculateVerticalProjection and findBestGapMidpoint.

---

Outside diff comments:
In
`@cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/ui/viewmodel/ComputerVisionViewModel.kt`:
- Around line 143-146: In loadImageFromUri (the coroutine launched via
viewModelScope.launch) the catch (e: Exception) block swallows
CancellationException; change the error handling so CancellationException is
rethrown immediately (or check e is CancellationException and throw it) before
handling other exceptions so coroutine cancellation propagates correctly; leave
the existing Log.e(TAG, ...) and
_uiEffect.send(ComputerVisionEffect.ShowError(...)) for non-cancellation errors.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 25d76446-2ce0-433c-b217-232f2dc3d6f7

📥 Commits

Reviewing files that changed from the base of the PR and between 0975600 and c689644.

📒 Files selected for processing (3)
  • cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/ui/viewmodel/ComputerVisionViewModel.kt
  • cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/utils/BitmapUtils.kt
  • cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/utils/SmartBoundaryDetector.kt

Comment on lines +17 to +44
fun detectSmartBoundaries(
bitmap: Bitmap,
edgeIgnorePercent: Float = DEFAULT_EDGE_IGNORE_PERCENT
): Pair<Int, Int> {
val width = bitmap.width
val projection = calculateVerticalProjection(bitmap)
val minimumGapWidth = (width * MIN_GAP_WIDTH_PERCENT).toInt()

val ignoredEdgePixels = (width * edgeIgnorePercent).toInt()
val leftZoneEnd = (width * LEFT_ZONE_END_PERCENT).toInt()
val rightZoneStart = (width * RIGHT_ZONE_START_PERCENT).toInt()
val rightZoneEnd = width - ignoredEdgePixels

val leftSignal = projection.copyOfRange(ignoredEdgePixels, leftZoneEnd)
var (leftBound, leftGapLength) = findBestGapMidpoint(leftSignal, offset = ignoredEdgePixels)
if (leftBound == null || leftGapLength < minimumGapWidth) {
leftBound = findBestGapMidpoint(leftSignal, offset = ignoredEdgePixels, normalizeSignal = true).first
}

val rightSignal = projection.copyOfRange(rightZoneStart, rightZoneEnd)
var (rightBound, rightGapLength) = findBestGapMidpoint(rightSignal, offset = rightZoneStart)
if (rightBound == null || rightGapLength < minimumGapWidth) {
rightBound = findBestGapMidpoint(rightSignal, offset = rightZoneStart, normalizeSignal = true).first
}

val finalLeftBound = leftBound ?: (width * LEFT_FALLBACK_BOUND_PERCENT).toInt()
val finalRightBound = rightBound ?: (width * RIGHT_FALLBACK_BOUND_PERCENT).toInt()
return Pair(finalLeftBound, finalRightBound)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Validate edgeIgnorePercent and range bounds before copyOfRange.

Current logic can throw IllegalArgumentException when callers pass invalid edgeIgnorePercent (e.g., negative or too large), because computed start/end indices may invert.

🛡️ Proposed fix
     fun detectSmartBoundaries(
         bitmap: Bitmap,
         edgeIgnorePercent: Float = DEFAULT_EDGE_IGNORE_PERCENT
     ): Pair<Int, Int> {
         val width = bitmap.width
+        if (width <= 0) {
+            return Pair(0, 0)
+        }
+
+        val safeEdgeIgnorePercent = edgeIgnorePercent.coerceIn(0f, 0.49f)
         val projection = calculateVerticalProjection(bitmap)
         val minimumGapWidth = (width * MIN_GAP_WIDTH_PERCENT).toInt()
 
-        val ignoredEdgePixels = (width * edgeIgnorePercent).toInt()
-        val leftZoneEnd = (width * LEFT_ZONE_END_PERCENT).toInt()
-        val rightZoneStart = (width * RIGHT_ZONE_START_PERCENT).toInt()
-        val rightZoneEnd = width - ignoredEdgePixels
+        val ignoredEdgePixels = (width * safeEdgeIgnorePercent).toInt()
+        val leftZoneEnd = (width * LEFT_ZONE_END_PERCENT).toInt().coerceAtLeast(ignoredEdgePixels)
+        val rightZoneStart = (width * RIGHT_ZONE_START_PERCENT).toInt().coerceIn(0, width)
+        val rightZoneEnd = (width - ignoredEdgePixels).coerceAtLeast(rightZoneStart)
 
         val leftSignal = projection.copyOfRange(ignoredEdgePixels, leftZoneEnd)
         var (leftBound, leftGapLength) = findBestGapMidpoint(leftSignal, offset = ignoredEdgePixels)
         if (leftBound == null || leftGapLength < minimumGapWidth) {
             leftBound = findBestGapMidpoint(leftSignal, offset = ignoredEdgePixels, normalizeSignal = true).first
         }
 
         val rightSignal = projection.copyOfRange(rightZoneStart, rightZoneEnd)
         var (rightBound, rightGapLength) = findBestGapMidpoint(rightSignal, offset = rightZoneStart)
         if (rightBound == null || rightGapLength < minimumGapWidth) {
             rightBound = findBestGapMidpoint(rightSignal, offset = rightZoneStart, normalizeSignal = true).first
         }
 
-        val finalLeftBound = leftBound ?: (width * LEFT_FALLBACK_BOUND_PERCENT).toInt()
-        val finalRightBound = rightBound ?: (width * RIGHT_FALLBACK_BOUND_PERCENT).toInt()
+        val fallbackLeft = (width * LEFT_FALLBACK_BOUND_PERCENT).toInt()
+        val fallbackRight = (width * RIGHT_FALLBACK_BOUND_PERCENT).toInt()
+        val finalLeftBound = (leftBound ?: fallbackLeft).coerceIn(0, width - 1)
+        val finalRightBound = (rightBound ?: fallbackRight).coerceIn(finalLeftBound, width - 1)
         return Pair(finalLeftBound, finalRightBound)
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
fun detectSmartBoundaries(
bitmap: Bitmap,
edgeIgnorePercent: Float = DEFAULT_EDGE_IGNORE_PERCENT
): Pair<Int, Int> {
val width = bitmap.width
val projection = calculateVerticalProjection(bitmap)
val minimumGapWidth = (width * MIN_GAP_WIDTH_PERCENT).toInt()
val ignoredEdgePixels = (width * edgeIgnorePercent).toInt()
val leftZoneEnd = (width * LEFT_ZONE_END_PERCENT).toInt()
val rightZoneStart = (width * RIGHT_ZONE_START_PERCENT).toInt()
val rightZoneEnd = width - ignoredEdgePixels
val leftSignal = projection.copyOfRange(ignoredEdgePixels, leftZoneEnd)
var (leftBound, leftGapLength) = findBestGapMidpoint(leftSignal, offset = ignoredEdgePixels)
if (leftBound == null || leftGapLength < minimumGapWidth) {
leftBound = findBestGapMidpoint(leftSignal, offset = ignoredEdgePixels, normalizeSignal = true).first
}
val rightSignal = projection.copyOfRange(rightZoneStart, rightZoneEnd)
var (rightBound, rightGapLength) = findBestGapMidpoint(rightSignal, offset = rightZoneStart)
if (rightBound == null || rightGapLength < minimumGapWidth) {
rightBound = findBestGapMidpoint(rightSignal, offset = rightZoneStart, normalizeSignal = true).first
}
val finalLeftBound = leftBound ?: (width * LEFT_FALLBACK_BOUND_PERCENT).toInt()
val finalRightBound = rightBound ?: (width * RIGHT_FALLBACK_BOUND_PERCENT).toInt()
return Pair(finalLeftBound, finalRightBound)
fun detectSmartBoundaries(
bitmap: Bitmap,
edgeIgnorePercent: Float = DEFAULT_EDGE_IGNORE_PERCENT
): Pair<Int, Int> {
val width = bitmap.width
if (width <= 0) {
return Pair(0, 0)
}
val safeEdgeIgnorePercent = edgeIgnorePercent.coerceIn(0f, 0.49f)
val projection = calculateVerticalProjection(bitmap)
val minimumGapWidth = (width * MIN_GAP_WIDTH_PERCENT).toInt()
val ignoredEdgePixels = (width * safeEdgeIgnorePercent).toInt()
val leftZoneEnd = (width * LEFT_ZONE_END_PERCENT).toInt().coerceAtLeast(ignoredEdgePixels)
val rightZoneStart = (width * RIGHT_ZONE_START_PERCENT).toInt().coerceIn(0, width)
val rightZoneEnd = (width - ignoredEdgePixels).coerceAtLeast(rightZoneStart)
val leftSignal = projection.copyOfRange(ignoredEdgePixels, leftZoneEnd)
var (leftBound, leftGapLength) = findBestGapMidpoint(leftSignal, offset = ignoredEdgePixels)
if (leftBound == null || leftGapLength < minimumGapWidth) {
leftBound = findBestGapMidpoint(leftSignal, offset = ignoredEdgePixels, normalizeSignal = true).first
}
val rightSignal = projection.copyOfRange(rightZoneStart, rightZoneEnd)
var (rightBound, rightGapLength) = findBestGapMidpoint(rightSignal, offset = rightZoneStart)
if (rightBound == null || rightGapLength < minimumGapWidth) {
rightBound = findBestGapMidpoint(rightSignal, offset = rightZoneStart, normalizeSignal = true).first
}
val fallbackLeft = (width * LEFT_FALLBACK_BOUND_PERCENT).toInt()
val fallbackRight = (width * RIGHT_FALLBACK_BOUND_PERCENT).toInt()
val finalLeftBound = (leftBound ?: fallbackLeft).coerceIn(0, width - 1)
val finalRightBound = (rightBound ?: fallbackRight).coerceIn(finalLeftBound, width - 1)
return Pair(finalLeftBound, finalRightBound)
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/utils/SmartBoundaryDetector.kt`
around lines 17 - 44, The detectSmartBoundaries function can pass invalid
start/end indices into projection.copyOfRange when edgeIgnorePercent is negative
or too large; before slicing the projection (and before using
leftZoneEnd/rightZoneStart/rightZoneEnd) clamp and validate computed indices:
compute ignoredEdgePixels = clamp((width * edgeIgnorePercent).toInt(), 0,
width), ensure leftZoneEnd = min((width * LEFT_ZONE_END_PERCENT).toInt(), width)
and rightZoneStart = max((width * RIGHT_ZONE_START_PERCENT).toInt(), 0), and
ensure rightZoneEnd = max(width - ignoredEdgePixels, rightZoneStart); if any
computed slice would be empty or inverted, skip findBestGapMidpoint calls and
fall back to LEFT_FALLBACK_BOUND_PERCENT/RIGHT_FALLBACK_BOUND_PERCENT (or call
findBestGapMidpoint with an empty-safe path like normalizeSignal=true) so
copyOfRange is never called with invalid ranges in detectSmartBoundaries while
keeping references to calculateVerticalProjection and findBestGapMidpoint.

@jatezzz jatezzz closed this Apr 9, 2026
@jatezzz jatezzz deleted the feat/ADFA-3290-smart-boundary-detection branch April 9, 2026 22:00
@jatezzz
Copy link
Copy Markdown
Collaborator Author

jatezzz commented Apr 9, 2026

Closed due to error on branch name
cc: @hal-eisen-adfa

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant