<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Projects | Saikiran Juttu | Robotics Portfolio</title><link>https://juttu-s.github.io/saikiran_juttu.github.io/project/</link><atom:link href="https://juttu-s.github.io/saikiran_juttu.github.io/project/index.xml" rel="self" type="application/rss+xml"/><description>Projects</description><generator>Hugo Blox Builder (https://hugoblox.com)</generator><language>en-us</language><lastBuildDate>Thu, 27 Aug 2026 00:00:00 +0000</lastBuildDate><image><url>https://juttu-s.github.io/saikiran_juttu.github.io/media/icon_hu7729264130191091259.png</url><title>Projects</title><link>https://juttu-s.github.io/saikiran_juttu.github.io/project/</link></image><item><title>Debugging OpenVLA on LIBERO — From 0% to 99% Success</title><link>https://juttu-s.github.io/saikiran_juttu.github.io/project/vla-libero-bench/</link><pubDate>Thu, 27 Aug 2026 00:00:00 +0000</pubDate><guid>https://juttu-s.github.io/saikiran_juttu.github.io/project/vla-libero-bench/</guid><description>&lt;p>A &lt;strong>vision-language-action (VLA)&lt;/strong> model that &amp;ldquo;loads successfully&amp;rdquo; and a VLA model that &lt;em>works&lt;/em> are two very different things. This project benchmarks &lt;strong>OpenVLA&lt;/strong> and &lt;strong>OpenVLA-OFT&lt;/strong> on the &lt;strong>LIBERO&lt;/strong> manipulation benchmark, and documents the gap between those two states.&lt;/p>
&lt;p>The headline result is not the final accuracy — it is that a naive but seemingly correct integration scored &lt;strong>0% across 250 episodes&lt;/strong>, and every point of recovery came from a specific, identifiable bug at the model–simulator boundary.&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>Code:&lt;/strong> &lt;a href="https://github.com/juttu-s/vla-libero-bench" target="_blank" rel="noopener">github.com/juttu-s/vla-libero-bench&lt;/a>&lt;/p>
&lt;/blockquote>
&lt;hr>
&lt;h2 id="results">Results&lt;/h2>
&lt;p>Each configuration was evaluated over &lt;strong>250 episodes&lt;/strong>, with 95% Wilson confidence intervals.&lt;/p>
&lt;h3 id="libero-spatial">LIBERO-Spatial&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Configuration&lt;/th>
&lt;th>Success Rate&lt;/th>
&lt;th>95% CI&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Base OpenVLA — naive integration&lt;/td>
&lt;td>&lt;strong>0.0%&lt;/strong>&lt;/td>
&lt;td>—&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Base OpenVLA — after bug fixes&lt;/td>
&lt;td>&lt;strong>51.6%&lt;/strong> (129/250)&lt;/td>
&lt;td>45.4 – 57.7%&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>OpenVLA-OFT&lt;/td>
&lt;td>&lt;strong>99.2%&lt;/strong> (248/250)&lt;/td>
&lt;td>97.1 – 99.8%&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h3 id="libero-object">LIBERO-Object&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Configuration&lt;/th>
&lt;th>Success Rate&lt;/th>
&lt;th>95% CI&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Base model, zero-shot&lt;/td>
&lt;td>&lt;strong>0.0%&lt;/strong>&lt;/td>
&lt;td>—&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Custom LoRA fine-tune&lt;/td>
&lt;td>collapsed&lt;/td>
&lt;td>—&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Official fine-tuned checkpoint&lt;/td>
&lt;td>&lt;strong>88.8%&lt;/strong> (222/250)&lt;/td>
&lt;td>84.3 – 92.1%&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The official checkpoint result reproduces the published figure, which validates the harness itself — a necessary control before trusting any of the other numbers.&lt;/p>
&lt;hr>
&lt;h2 id="the-eight-bugs">The Eight Bugs&lt;/h2>
&lt;p>Each of these produced either silent failure or plausible-but-wrong behaviour. None raised an exception.&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Attention mask mismatch&lt;/strong> in &lt;code>transformers&lt;/code> — the installed attention backend disagreed with the mask layout the checkpoint expected. Resolved by pinning the &lt;strong>SDPA&lt;/strong> implementation.&lt;/li>
&lt;li>&lt;strong>Image orientation&lt;/strong> — frames required a &lt;strong>180° rotation&lt;/strong>, not the vertical flip that the obvious reading of the simulator convention suggests. A vertical flip produces images that look approximately right, which is exactly why it survives inspection.&lt;/li>
&lt;li>&lt;strong>Inverted gripper convention&lt;/strong> — the sign of the gripper channel is opposite between the training data and the simulation environment, so the policy opened when it intended to close.&lt;/li>
&lt;li>&lt;strong>Center-crop semantics&lt;/strong> — the documented 90% crop is &lt;strong>90% of &lt;em>area&lt;/em>&lt;/strong>, not 90% per side. Getting this wrong shifts the effective field of view enough to break spatial grounding.&lt;/li>
&lt;li>&lt;strong>Gripper command duration&lt;/strong> — single-step gripper commands are insufficient; the actuator needs &lt;strong>temporal persistence with hysteresis&lt;/strong> to actually complete a grasp.&lt;/li>
&lt;li>&lt;strong>Dataset statistics merging&lt;/strong> — normalization statistics from fine-tuning were only partially merged into the checkpoint, so actions were de-normalized against the wrong distribution.&lt;/li>
&lt;li>&lt;strong>LIBERO package mapping&lt;/strong> — a missing &lt;code>__init__&lt;/code> file caused the installed package to expose empty module mappings, silently resolving task suites to nothing.&lt;/li>
&lt;li>&lt;strong>Headless reset hang&lt;/strong> — exception handling inside environment reset deadlocked under headless OpenGL rendering rather than surfacing an error.&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="method">Method&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Models:&lt;/strong> OpenVLA (7B, base and LoRA-fine-tuned) and OpenVLA-OFT&lt;/li>
&lt;li>&lt;strong>Benchmark:&lt;/strong> LIBERO-Spatial and LIBERO-Object, 250 episodes per configuration&lt;/li>
&lt;li>&lt;strong>Simulator:&lt;/strong> MuJoCo with configurable OpenGL / EGL rendering backends&lt;/li>
&lt;li>&lt;strong>Diagnostics:&lt;/strong> Per-bug isolation scripts under &lt;code>scripts/&lt;/code>, with debug frame captures in &lt;code>assets/&lt;/code> to visually confirm preprocessing at each stage&lt;/li>
&lt;li>&lt;strong>Reproducibility:&lt;/strong> Pinned Conda environments under &lt;code>env/&lt;/code>, results as raw episode records under &lt;code>results/&lt;/code>&lt;/li>
&lt;/ul>
&lt;h2 id="working-under-a-single-gpu-budget">Working Under a Single-GPU Budget&lt;/h2>
&lt;p>The whole study ran on &lt;strong>one 24 GB GPU&lt;/strong>, which shaped the design:&lt;/p>
&lt;ul>
&lt;li>LoRA fine-tuning at &lt;strong>batch size 2 with 8× gradient accumulation&lt;/strong> to fit in memory&lt;/li>
&lt;li>&lt;strong>SDPA&lt;/strong> attention instead of flash-attention, since no CUDA toolkit was available to build it&lt;/li>
&lt;li>The custom LoRA run &lt;strong>collapsed&lt;/strong> — reported rather than hidden, because it marks the real boundary of what this hardware budget supports&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="takeaway">Takeaway&lt;/h2>
&lt;p>The 51.6% → 99.2% jump from OpenVLA to OpenVLA-OFT is a genuine architectural result. The 0% → 51.6% jump is not a result at all — it is the cost of integration, and it is invisible in every paper that reports only the final number. Publishing the bug list is the useful contribution here.&lt;/p></description></item><item><title>Autonomous Race Driving with Action Mapping RL</title><link>https://juttu-s.github.io/saikiran_juttu.github.io/project/am-race-driving-rl/</link><pubDate>Thu, 10 Apr 2025 00:00:00 +0000</pubDate><guid>https://juttu-s.github.io/saikiran_juttu.github.io/project/am-race-driving-rl/</guid><description>&lt;h2 id="autonomous-race-driving-with-action-mapping-rl">Autonomous Race Driving with Action Mapping RL&lt;/h2>
&lt;p>This project evaluates the impact of &lt;strong>Action Mapping (AM)&lt;/strong> on &lt;strong>deep reinforcement learning (DRL)&lt;/strong> algorithms for high-speed autonomous race driving. The primary goal was to enforce &lt;strong>friction and vehicle dynamics constraints&lt;/strong> while ensuring agents could learn to complete laps efficiently and safely.&lt;/p>
&lt;p>We &lt;strong>extended the GitHub framework from the AM-RL paper&lt;/strong> (ISA Transactions 2024) that used TD3 + AM, and &lt;strong>re-implemented the actor-critic models using DDPG and SAC&lt;/strong> with Action Mapping. The experiments were conducted on &lt;strong>Track A&lt;/strong> in the custom race simulator.&lt;/p>
&lt;hr>
&lt;h2 id="objectives">Objectives&lt;/h2>
&lt;ul>
&lt;li>Understand and replicate the Action Mapping Reinforcement Learning (AM-RL) framework&lt;/li>
&lt;li>Implement DDPG and SAC as alternate agents within the same setup&lt;/li>
&lt;li>Compare lap time, constraint satisfaction, and learning stability across methods&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="simulation--state-design">Simulation &amp;amp; State Design&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Environment:&lt;/strong> Custom Python-based simulator using bicycle model dynamics&lt;/li>
&lt;li>&lt;strong>Observations:&lt;/strong>
&lt;ul>
&lt;li>12 future curvature points from the centerline&lt;/li>
&lt;li>Velocity, yaw rate, orientation, steering angle, and track deviation&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>&lt;strong>Action Space:&lt;/strong> Virtual actions ∈ [−1, 1]² → throttle &amp;amp; steering angle&lt;/li>
&lt;li>&lt;strong>Constraints:&lt;/strong> Friction cone and lateral acceleration safety bounds&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="neural-networks">Neural Networks&lt;/h2>
&lt;h3 id="actor-networks">Actor Networks&lt;/h3>
&lt;ul>
&lt;li>Fully connected with 2 hidden layers (256–256 units)&lt;/li>
&lt;li>&lt;strong>Activation:&lt;/strong> ReLU (hidden), Tanh (output)&lt;/li>
&lt;li>&lt;strong>Output:&lt;/strong> Unconstrained actions (steering, throttle) passed to AM module&lt;/li>
&lt;/ul>
&lt;h3 id="critic-networks">Critic Networks&lt;/h3>
&lt;ul>
&lt;li>Two Q-functions for SAC (double critic)&lt;/li>
&lt;li>One Q-function for DDPG&lt;/li>
&lt;li>Each accepts state-action pair and outputs scalar Q-value&lt;/li>
&lt;/ul>
&lt;h3 id="loss-functions">Loss Functions&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>DDPG:&lt;/strong> MSE loss on predicted Q-values&lt;/li>
&lt;li>&lt;strong>SAC:&lt;/strong> Adds entropy regularization and automatic temperature tuning&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="action-mapping-am">Action Mapping (AM)&lt;/h2>
&lt;ul>
&lt;li>State-aware mapping transforms virtual actions to safe, real-world controls&lt;/li>
&lt;li>Implemented as a lookup-based mapping based on track geometry and physics&lt;/li>
&lt;li>Ensures compliance with dynamic tire-road constraints (Fₓ, Fᵧ within friction ellipse)&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="results-summary">Results Summary&lt;/h2>
&lt;h3 id="ddpg-with-action-mapping-am">DDPG with Action Mapping (AM)&lt;/h3>
&lt;ul>
&lt;li>Learned a stable and smooth racing policy&lt;/li>
&lt;li>Consistently finished laps on Track A&lt;/li>
&lt;li>&lt;strong>Lap 1 Time:&lt;/strong> 43.57s&lt;/li>
&lt;li>&lt;strong>Lap 2 Time:&lt;/strong> 41.7s&lt;/li>
&lt;li>Maintained safety constraints with no friction violations&lt;/li>
&lt;li>Approached TD3-level performance from literature&lt;/li>
&lt;/ul>
&lt;h3 id="td3-vs-ddpg-lap-comparison">TD3 vs DDPG Lap Comparison&lt;/h3>
&lt;p>Below is a side-by-side comparison of &lt;strong>TD3&lt;/strong> (left) and &lt;strong>DDPG&lt;/strong> (right) for both Lap 1 and Lap 2 on Track A. Color encodes speed (m/s).&lt;/p>
&lt;p>
&lt;figure >
&lt;div class="flex justify-center ">
&lt;div class="w-100" >&lt;img alt="TD3 vs DDPG Lap 1 &amp;amp; 2" srcset="
/saikiran_juttu.github.io/project/am-race-driving-rl/TD3_DDPG_hu11255701520106207767.webp 400w,
/saikiran_juttu.github.io/project/am-race-driving-rl/TD3_DDPG_hu16877567874459994854.webp 760w,
/saikiran_juttu.github.io/project/am-race-driving-rl/TD3_DDPG_hu9699183390352886908.webp 1200w"
src="https://juttu-s.github.io/saikiran_juttu.github.io/saikiran_juttu.github.io/project/am-race-driving-rl/TD3_DDPG_hu11255701520106207767.webp"
width="601"
height="760"
loading="lazy" data-zoomable />&lt;/div>
&lt;/div>&lt;/figure>
&lt;/p>
&lt;p>&lt;em>Figure: Lap 1 and Lap 2 performance comparison — TD3 has tighter trajectories and better cornering speed than DDPG.&lt;/em>&lt;/p>
&lt;hr>
&lt;h3 id="sac-with-action-mapping-am">SAC with Action Mapping (AM)&lt;/h3>
&lt;ul>
&lt;li>Frequently failed to complete laps (DNF)&lt;/li>
&lt;li>Produced two common failure modes:
&lt;ul>
&lt;li>Prioritizing speed → unstable cornering and off-track behavior&lt;/li>
&lt;li>Prioritizing stability → very slow but accurate turns&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Final policies were inconsistent and fragile&lt;/li>
&lt;li>Highly sensitive to reward scaling and actor update frequency&lt;/li>
&lt;/ul>
&lt;p>
&lt;figure >
&lt;div class="flex justify-center ">
&lt;div class="w-100" >&lt;img alt="SAC Lap 1 Intermediate Failures" srcset="
/saikiran_juttu.github.io/project/am-race-driving-rl/SAC_hu7060679015506459835.webp 400w,
/saikiran_juttu.github.io/project/am-race-driving-rl/SAC_hu17451594598186353989.webp 760w,
/saikiran_juttu.github.io/project/am-race-driving-rl/SAC_hu100650002741892404.webp 1200w"
src="https://juttu-s.github.io/saikiran_juttu.github.io/saikiran_juttu.github.io/project/am-race-driving-rl/SAC_hu7060679015506459835.webp"
width="655"
height="377"
loading="lazy" data-zoomable />&lt;/div>
&lt;/div>&lt;/figure>
&lt;/p>
&lt;p>&lt;em>Figure: SAC Lap 1 — Left: high speed, low stability; Right: better track alignment, very low velocity.&lt;/em>&lt;/p>
&lt;hr>
&lt;h2 id="key-comparison-table">Key Comparison Table&lt;/h2>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Metric&lt;/th>
&lt;th>TD3-AM (paper)&lt;/th>
&lt;th>DDPG-AM (ours)&lt;/th>
&lt;th>SAC-AM (ours)&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Best Lap Time (s)&lt;/td>
&lt;td>&lt;strong>36.94&lt;/strong>&lt;/td>
&lt;td>41.7&lt;/td>
&lt;td>DNF (unstable)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Constraint Violations&lt;/td>
&lt;td>0&lt;/td>
&lt;td>0&lt;/td>
&lt;td>Some (off-track)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Convergence Speed&lt;/td>
&lt;td>Fast&lt;/td>
&lt;td>Moderate&lt;/td>
&lt;td>Slow&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Stability&lt;/td>
&lt;td>High&lt;/td>
&lt;td>High&lt;/td>
&lt;td>Very Low&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;hr>
&lt;h2 id="resources">Resources&lt;/h2>
&lt;ul>
&lt;li>📄 &lt;a href="CS5180___Final_Report.pdf">Final Report PDF&lt;/a>&lt;/li>
&lt;li>🔗 &lt;a href="https://www.sciencedirect.com/science/article/pii/S0019057824002143?via%3Dihub" target="_blank" rel="noopener">Link to Original Paper&lt;/a>&lt;/li>
&lt;li>🔗 &lt;a href="https://github.com/s-bray/CS5180-Final_Project" target="_blank" rel="noopener">GitHub Repo&lt;/a>&lt;/li>
&lt;/ul>
&lt;hr>
&lt;p>This project demonstrates how &lt;strong>Action Mapping serves as a universal safety wrapper&lt;/strong> that can be applied across different DRL algorithms. Our experiments showed that DDPG performs competitively against TD3 in constrained driving, while SAC struggled to converge due to reward instability — emphasizing the importance of choosing the right policy architecture for safety-critical RL tasks.&lt;/p></description></item><item><title>Sparse 3D Reconstruction and Bundle Adjustment</title><link>https://juttu-s.github.io/saikiran_juttu.github.io/project/sparse-reconstruction/</link><pubDate>Thu, 07 Nov 2024 00:00:00 +0000</pubDate><guid>https://juttu-s.github.io/saikiran_juttu.github.io/project/sparse-reconstruction/</guid><description>&lt;p>This project implements a full Structure from Motion (SfM) pipeline on a &lt;strong>Buddha statue&lt;/strong> using a sequence of &lt;strong>24 grayscale images&lt;/strong>. It combines feature detection, epipolar geometry, camera pose recovery, triangulation, and bundle adjustment.&lt;/p>
&lt;hr>
&lt;h3 id="dataset">Dataset&lt;/h3>
&lt;ul>
&lt;li>24 images of a wooden Buddha statue captured at different angles&lt;/li>
&lt;li>Enhanced using &lt;strong>CLAHE (Contrast Limited Adaptive Histogram Equalization)&lt;/strong>&lt;/li>
&lt;li>Features extracted using &lt;strong>SIFT&lt;/strong> with custom parameters&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h3 id="pipeline-overview">Pipeline Overview&lt;/h3>
&lt;h4 id="1-image-preprocessing">1. Image Preprocessing&lt;/h4>
&lt;p>Using CLAHE improves contrast on low-texture surfaces like carved wood.&lt;/p>
&lt;div style="text-align: center;">
&lt;img src="processed.png" width="1000">
&lt;/div>
&lt;hr>
&lt;h4 id="2-sift-feature-detection">2. SIFT Feature Detection&lt;/h4>
&lt;ul>
&lt;li>Applied to all 24 images&lt;/li>
&lt;li>Used &lt;strong>BFMatcher&lt;/strong> with ratio test&lt;/li>
&lt;li>Matches filtered via &lt;strong>RANSAC&lt;/strong> for outlier rejection&lt;/li>
&lt;/ul>
&lt;div style="text-align: center;">
&lt;img src="features.png" width="1000">
&lt;/div>
&lt;hr>
&lt;h4 id="3-essential-matrix--pose-recovery">3. Essential Matrix &amp;amp; Pose Recovery&lt;/h4>
&lt;ul>
&lt;li>Computed Essential matrix using calibrated camera matrix&lt;/li>
&lt;li>Used &lt;code>cv2.recoverPose()&lt;/code> to derive relative rotation and translation between views&lt;/li>
&lt;li>Built a chain of camera poses from image 0 onward&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h4 id="4-triangulation">4. Triangulation&lt;/h4>
&lt;ul>
&lt;li>3D points computed from pixel correspondences using &lt;code>cv2.triangulatePoints()&lt;/code>&lt;/li>
&lt;li>All 3D points stored in homogeneous form&lt;/li>
&lt;li>Colored and visualized using Plotly&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h4 id="5-bundle-adjustment-with-gtsam">5. Bundle Adjustment with GTSAM&lt;/h4>
&lt;ul>
&lt;li>Built a factor graph with:
&lt;ul>
&lt;li>Camera pose priors&lt;/li>
&lt;li>Between factors from pose transitions&lt;/li>
&lt;li>Projection factors from 2D-3D matches&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>Used &lt;code>Levenberg-MarquardtOptimizer&lt;/code> for refinement&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h3 id="results">Results&lt;/h3>
&lt;h4 id="initial-3d-trajectory">Initial 3D Trajectory&lt;/h4>
&lt;p>
&lt;figure >
&lt;div class="flex justify-center ">
&lt;div class="w-100" >&lt;img alt="Initial Trajectory" srcset="
/saikiran_juttu.github.io/project/sparse-reconstruction/Initial_plot_hu12090084634136375331.webp 400w,
/saikiran_juttu.github.io/project/sparse-reconstruction/Initial_plot_hu6971695684859321629.webp 760w,
/saikiran_juttu.github.io/project/sparse-reconstruction/Initial_plot_hu11908849571792030370.webp 1200w"
src="https://juttu-s.github.io/saikiran_juttu.github.io/saikiran_juttu.github.io/project/sparse-reconstruction/Initial_plot_hu12090084634136375331.webp"
width="760"
height="282"
loading="lazy" data-zoomable />&lt;/div>
&lt;/div>&lt;/figure>
&lt;/p>
&lt;h4 id="optimized-3d-trajectory-after-bundle-adjustment">Optimized 3D Trajectory after Bundle Adjustment&lt;/h4>
&lt;p>
&lt;figure >
&lt;div class="flex justify-center ">
&lt;div class="w-100" >&lt;img alt="Optimized Trajectory" srcset="
/saikiran_juttu.github.io/project/sparse-reconstruction/Optimized_plot_hu1365288965832748826.webp 400w,
/saikiran_juttu.github.io/project/sparse-reconstruction/Optimized_plot_hu5432368712792554619.webp 760w,
/saikiran_juttu.github.io/project/sparse-reconstruction/Optimized_plot_hu6617160073620067270.webp 1200w"
src="https://juttu-s.github.io/saikiran_juttu.github.io/saikiran_juttu.github.io/project/sparse-reconstruction/Optimized_plot_hu1365288965832748826.webp"
width="760"
height="282"
loading="lazy" data-zoomable />&lt;/div>
&lt;/div>&lt;/figure>
&lt;/p>
&lt;ul>
&lt;li>Average reprojection error reduced by ~15%&lt;/li>
&lt;li>Landmark cloud tightened around object geometry&lt;/li>
&lt;li>Rotation drift corrected with global optimization&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h3 id="tools--libraries">Tools &amp;amp; Libraries&lt;/h3>
&lt;ul>
&lt;li>OpenCV (SIFT, RANSAC, triangulation)&lt;/li>
&lt;li>NumPy, Matplotlib, Plotly&lt;/li>
&lt;li>GTSAM (factor graph + BA)&lt;/li>
&lt;li>Python&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h3 id="resources">Resources&lt;/h3>
&lt;ul>
&lt;li>🔗 &lt;a href="https://github.com/juttu-s/Sparse-3D-Reconstruction" target="_blank" rel="noopener">GitHub Repo&lt;/a>&lt;/li>
&lt;/ul>
&lt;hr>
&lt;p>This project demonstrates a scalable pipeline for SfM using minimal dependencies. It serves as a foundation for integrating real-time VIO or stereo SLAM on embedded platforms.&lt;/p></description></item><item><title>Photo Mosaicking of Low-Contrast Underwater Images</title><link>https://juttu-s.github.io/saikiran_juttu.github.io/project/photo-mosaicking/</link><pubDate>Sat, 05 Oct 2024 00:00:00 +0000</pubDate><guid>https://juttu-s.github.io/saikiran_juttu.github.io/project/photo-mosaicking/</guid><description>&lt;p>This project implements a full photo mosaicking and optimization pipeline using low-contrast underwater images from the &lt;strong>Skerki Bank Roman shipwreck&lt;/strong> dataset. The approach registers both sequential and non-sequential images using SIFT and RANSAC, computes affine transformations, and optimizes a global trajectory using GTSAM.&lt;/p>
&lt;hr>
&lt;h3 id="pipeline-breakdown">Pipeline Breakdown&lt;/h3>
&lt;h4 id="clahe-image-enhancement">CLAHE Image Enhancement&lt;/h4>
&lt;ul>
&lt;li>Applies histogram equalization to improve contrast and enhance keypoints.&lt;/li>
&lt;li>OpenCV CLAHE was used on each grayscale frame.&lt;/li>
&lt;/ul>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="n">clahe&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">cv2&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">createCLAHE&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">clipLimit&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mf">2.0&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">tileGridSize&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">8&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">8&lt;/span>&lt;span class="p">))&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">clahe_image&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">clahe&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">apply&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">gray_image&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;hr>
&lt;h4 id="sift-feature-detection">SIFT Feature Detection&lt;/h4>
&lt;ul>
&lt;li>Detected keypoints using tuned SIFT settings:
&lt;ul>
&lt;li>&lt;code>nfeatures=5000&lt;/code>, &lt;code>contrastThreshold=0.025&lt;/code>, &lt;code>nOctaveLayers=8&lt;/code>, &lt;code>sigma=1.5&lt;/code>&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="n">sift&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">cv2&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">SIFT_create&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">...&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">kp&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">desc&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">sift&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">detectAndCompute&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">image&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kc">None&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;hr>
&lt;h4 id="feature-matching--ransac-filtering">Feature Matching + RANSAC Filtering&lt;/h4>
&lt;ul>
&lt;li>Matched descriptors using Brute-Force Matcher + Lowe’s ratio test.&lt;/li>
&lt;li>Applied &lt;code>cv2.estimateAffine2D&lt;/code> with RANSAC to compute and refine transformation.&lt;/li>
&lt;/ul>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="n">matches&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">bf&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">knnMatch&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">des1&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">des2&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">k&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">2&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">good&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="n">m&lt;/span> &lt;span class="k">for&lt;/span> &lt;span class="n">m&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">n&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">matches&lt;/span> &lt;span class="k">if&lt;/span> &lt;span class="n">m&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">distance&lt;/span> &lt;span class="o">&amp;lt;&lt;/span> &lt;span class="mf">0.75&lt;/span> &lt;span class="o">*&lt;/span> &lt;span class="n">n&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">distance&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">H&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">mask&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">cv2&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">estimateAffine2D&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">pts1&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">pts2&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">method&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">cv2&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">RANSAC&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;hr>
&lt;h4 id="pose-graph-construction-gtsam">Pose Graph Construction (GTSAM)&lt;/h4>
&lt;ul>
&lt;li>Built a factor graph using all non-repeating image pairs.&lt;/li>
&lt;li>Relative poses (affine transforms) were added as edges.&lt;/li>
&lt;/ul>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="n">graph&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">add&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">BetweenFactorPose2&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">i1&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">i2&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">T_ij&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">noise_model&lt;/span>&lt;span class="p">))&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="initial-trajectoryplot_beforepng">
&lt;figure >
&lt;div class="flex justify-center ">
&lt;div class="w-100" >&lt;img alt="Initial Trajectory" srcset="
/saikiran_juttu.github.io/project/photo-mosaicking/plot_before_hu6875028771095386571.webp 400w,
/saikiran_juttu.github.io/project/photo-mosaicking/plot_before_hu3546410195309766098.webp 760w,
/saikiran_juttu.github.io/project/photo-mosaicking/plot_before_hu3825931163024726418.webp 1200w"
src="https://juttu-s.github.io/saikiran_juttu.github.io/saikiran_juttu.github.io/project/photo-mosaicking/plot_before_hu6875028771095386571.webp"
width="571"
height="455"
loading="lazy" data-zoomable />&lt;/div>
&lt;/div>&lt;/figure>
&lt;/h2>
&lt;h4 id="global-bundle-adjustment">Global Bundle Adjustment&lt;/h4>
&lt;ul>
&lt;li>Used GTSAM’s Levenberg-Marquardt optimizer to refine global poses.&lt;/li>
&lt;li>Corrects drift and adjusts poses to minimize total residual error.&lt;/li>
&lt;/ul>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="n">optimizer&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">gtsam&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">LevenbergMarquardtOptimizer&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">graph&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">initial_estimate&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">result&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">optimizer&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">optimize&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>
&lt;figure >
&lt;div class="flex justify-center ">
&lt;div class="w-100" >&lt;img alt="Optimized Trajectory" srcset="
/saikiran_juttu.github.io/project/photo-mosaicking/plot_after_hu16779151673749428898.webp 400w,
/saikiran_juttu.github.io/project/photo-mosaicking/plot_after_hu14280678253068865284.webp 760w,
/saikiran_juttu.github.io/project/photo-mosaicking/plot_after_hu13790384701387224375.webp 1200w"
src="https://juttu-s.github.io/saikiran_juttu.github.io/saikiran_juttu.github.io/project/photo-mosaicking/plot_after_hu16779151673749428898.webp"
width="580"
height="455"
loading="lazy" data-zoomable />&lt;/div>
&lt;/div>&lt;/figure>
&lt;/p>
&lt;hr>
&lt;h3 id="techniques-used">Techniques Used&lt;/h3>
&lt;ul>
&lt;li>Image normalization + CLAHE&lt;/li>
&lt;li>SIFT feature detection and matching&lt;/li>
&lt;li>RANSAC for outlier rejection&lt;/li>
&lt;li>Homography estimation using Levenberg–Marquardt&lt;/li>
&lt;li>Graph construction (GTSAM)&lt;/li>
&lt;li>Loop closure detection&lt;/li>
&lt;li>Pose optimization&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h3 id="results">Results&lt;/h3>
&lt;ul>
&lt;li>Successfully registered both sequential and non-sequential image pairs&lt;/li>
&lt;li>Constructed optimized pose graphs for 6 and 29 image subsets&lt;/li>
&lt;li>Achieved a ~20% improvement in alignment after bundle adjustment&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h3 id="-related-files">📁 Related Files&lt;/h3>
&lt;ul>
&lt;li>🔗 &lt;a href="https://github.com/juttu-s/photo-mosaicking-skerki" target="_blank" rel="noopener">GitHub Repository&lt;/a>&lt;/li>
&lt;li>📁 &lt;a href="https://drive.google.com/drive/folders/1AtvT65txGIgAG23NRs3EkvDET036a81O" target="_blank" rel="noopener">Skerki Dataset Reference (Google Drive)&lt;/a>&lt;/li>
&lt;li>📓 &lt;a href="https://juttu-s.github.io/saikiran_juttu.github.io/files/Part1_and_2.ipynb">Project Notebook&lt;/a>&lt;/li>
&lt;li>📓 &lt;a href="https://juttu-s.github.io/saikiran_juttu.github.io/files/Part_3.ipynb">Extended Analysis&lt;/a>&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h3 id="references">References&lt;/h3>
&lt;ul>
&lt;li>Pizarro &amp;amp; Singh (2003): &lt;em>Toward large-area mosaicing for underwater scientific applications.&lt;/em>&lt;/li>
&lt;li>Ballard et al. (1998, 2000): &lt;em>Roman shipwreck discovery using submersible tech.&lt;/em>&lt;/li>
&lt;/ul></description></item><item><title>Kimera VIO on EuRoC and Custom Datasets</title><link>https://juttu-s.github.io/saikiran_juttu.github.io/project/kimera-vio/</link><pubDate>Fri, 26 Apr 2024 00:00:00 +0000</pubDate><guid>https://juttu-s.github.io/saikiran_juttu.github.io/project/kimera-vio/</guid><description>&lt;p>An evaluation and deployment project rather than an algorithms one. &lt;a href="https://github.com/MIT-SPARK/Kimera-VIO" target="_blank" rel="noopener">Kimera-VIO&lt;/a> from MIT-SPARK was used as shipped; the work here was getting it running reproducibly, reproducing its published benchmark on EuRoC, and then pointing it at a sequence it had never seen — recorded on an Intel RealSense D455 in an ordinary office — to find out what breaks when you leave the benchmark behind.&lt;/p>
&lt;hr>
&lt;h2 id="what-kimera-vio-is-doing">What Kimera-VIO is doing&lt;/h2>
&lt;p>Worth stating, because the interesting failure later only makes sense against it.&lt;/p>
&lt;p>&lt;strong>Front end.&lt;/strong> Shi-Tomasi corners on the left stereo image, tracked temporally with Lucas–Kanade optical flow, matched left-to-right and triangulated for depth, with outlier rejection on the resulting correspondences. IMU measurements between keyframes are preintegrated into a single relative-motion constraint and used for gravity alignment.&lt;/p>
&lt;p>&lt;strong>Back end.&lt;/strong> A factor graph over position, orientation and velocity, with IMU factors and visual reprojection factors as edges, optimised incrementally by &lt;strong>iSAM2&lt;/strong> in GTSAM. Robust losses (Huber, Cauchy) absorb the outliers that survive the front end. Output is a pose trajectory, velocity estimates and a sparse landmark map.&lt;/p>
&lt;p>&lt;strong>Loop closure.&lt;/strong> Kimera-RPGO detects loops and runs robust pose-graph optimisation over them to produce a globally consistent trajectory.&lt;/p>
&lt;h2 id="reproducibility">Reproducibility&lt;/h2>
&lt;p>The deliverable that mattered most was a &lt;strong>saved Docker image&lt;/strong> with Kimera-VIO, ROS and every dependency preinstalled — distributed as a tarball so the whole pipeline comes up in three commands:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">docker load -i kimera-vio.tar
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">docker run -it kimera-vio
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">python3 visual.py data_pgo.csv &lt;span class="c1"># inside the container&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Anyone who has built a VIO stack from source knows why this is worth doing. Kimera-VIO wants a specific GTSAM, which wants a specific Boost and Eigen, and the failure mode of getting it wrong is a pipeline that compiles and then silently estimates garbage. Pinning the whole environment means a result someone else can actually reproduce, and it makes &amp;ldquo;does this parameter change help?&amp;rdquo; a question you can answer in an afternoon instead of a week.&lt;/p>
&lt;p>Stereo image pairs and IMU streams were synchronised and fed in through ROS.&lt;/p>
&lt;hr>
&lt;h2 id="euroc-benchmark">EuRoC benchmark&lt;/h2>
&lt;p>Run on the &lt;strong>EuRoC MAV&lt;/strong> Vicon Room 1 sequences — &lt;code>V1_01&lt;/code>, &lt;code>V1_02&lt;/code> and &lt;code>V1_03&lt;/code>, spanning easy, medium and difficult, with motion-capture ground truth. That&amp;rsquo;s three of the dataset&amp;rsquo;s eleven sequences; the Machine Hall and Vicon Room 2 sets weren&amp;rsquo;t covered.&lt;/p>
&lt;p>ATE RMSE in metres, reproducing the published comparison across VIO pipelines:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Sequence&lt;/th>
&lt;th>OKVIS&lt;/th>
&lt;th>MSCKF&lt;/th>
&lt;th>ROVIO&lt;/th>
&lt;th>VINS-Mono&lt;/th>
&lt;th>Kimera-VIO&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>V1_01&lt;/td>
&lt;td>0.09&lt;/td>
&lt;td>0.34&lt;/td>
&lt;td>0.10&lt;/td>
&lt;td>0.08&lt;/td>
&lt;td>&lt;strong>0.05&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>V1_02&lt;/td>
&lt;td>0.20&lt;/td>
&lt;td>0.20&lt;/td>
&lt;td>0.10&lt;/td>
&lt;td>0.11&lt;/td>
&lt;td>&lt;strong>0.08&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>V1_03&lt;/td>
&lt;td>0.24&lt;/td>
&lt;td>0.67&lt;/td>
&lt;td>0.14&lt;/td>
&lt;td>0.18&lt;/td>
&lt;td>&lt;strong>0.07&lt;/strong>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The pattern worth noting is the third row. Every other pipeline degrades on &lt;code>V1_03&lt;/code> — the difficult sequence, with faster and more aggressive motion — while Kimera-VIO holds at 0.07 m. Aggressive motion is &lt;em>good&lt;/em> for a visual-inertial system: it excites the IMU, which is what makes scale and gravity direction observable in the first place. Getting parameters into the range where that benchmark reproduces meant tuning IMU noise models, feature thresholds and optimiser tolerances. Throughput came out around &lt;strong>25 FPS&lt;/strong>.&lt;/p>
&lt;h2 id="custom-dataset">Custom dataset&lt;/h2>
&lt;p>Recorded with a &lt;strong>RealSense D455&lt;/strong> in a room of desks, monitors and the usual office clutter, walking an approximately oval path back to the starting point.&lt;/p>
&lt;p>
&lt;figure >
&lt;div class="flex justify-center ">
&lt;div class="w-100" >&lt;img alt="The post-optimisation trajectory recovering the oval path walked during recording" srcset="
/saikiran_juttu.github.io/project/kimera-vio/trajectory_hu18271531692901669461.webp 400w,
/saikiran_juttu.github.io/project/kimera-vio/trajectory_hu14690658229423064687.webp 760w,
/saikiran_juttu.github.io/project/kimera-vio/trajectory_hu16662803654171847119.webp 1200w"
src="https://juttu-s.github.io/saikiran_juttu.github.io/saikiran_juttu.github.io/project/kimera-vio/trajectory_hu18271531692901669461.webp"
width="760"
height="576"
loading="lazy" data-zoomable />&lt;/div>
&lt;/div>&lt;/figure>
&lt;em>Trajectory after pose-graph optimisation. The plot renders the path in the x–y plane, so vertical motion isn&amp;rsquo;t shown.&lt;/em>&lt;/p>
&lt;p>There is &lt;strong>no ground truth&lt;/strong> for this sequence — no motion capture in an office — so everything here is qualitative. No ATE, no drift percentage, and any number attached to it would be invented. What it does show is loop closure working: the path returns to where it started rather than spiralling away.&lt;/p>
&lt;h3 id="starting-at-rest">Starting at rest&lt;/h3>
&lt;p>The most useful thing this sequence taught me was a failure at the very beginning of the recording, and it isn&amp;rsquo;t a bug — it&amp;rsquo;s a property of the problem.&lt;/p>
&lt;p>Visual-inertial odometry recovers metric scale and gravity direction from the accelerometer. But an accelerometer at rest measures only gravity, and there is no way to separate &amp;ldquo;which way is down&amp;rdquo; from &amp;ldquo;how fast am I accelerating&amp;rdquo; without motion to disambiguate them. Start recording with the camera stationary and the initialiser has no excitation to work with; it converges on a poor estimate, and that error is baked into every pose that follows.&lt;/p>
&lt;p>That&amp;rsquo;s exactly what happened — visible drift accumulated from the start of the trajectory, and it persisted until loop closure corrected it. It also explains the EuRoC pattern above: the &amp;ldquo;difficult&amp;rdquo; sequence is easier for VIO in this one specific respect. The practical lesson is that a data-collection protocol matters as much as a config file: move the camera through a few seconds of varied motion &lt;strong>before&lt;/strong> anything you care about, and the whole run improves.&lt;/p>
&lt;p>Elsewhere in the sequence, low-texture regions produced feature sparsity and minor drift, which pose-graph optimisation largely absorbed.&lt;/p>
&lt;h2 id="limits-and-next-steps">Limits and next steps&lt;/h2>
&lt;p>Kimera-VIO&amp;rsquo;s weak points on this data were the ones its authors document: drift in low-texture regions, and sensitivity to outlier visual measurements. The obvious extensions, in order of how much they&amp;rsquo;d change the picture:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Metric-semantic mapping.&lt;/strong> This used the VIO half of the Kimera suite only. Kimera-Semantics builds a semantically annotated mesh from the same pipeline, which is where the interesting downstream work is.&lt;/li>
&lt;li>&lt;strong>Outdoor and larger scale&lt;/strong>, to see where the factor graph stops being tractable.&lt;/li>
&lt;li>&lt;strong>LiDAR as an additional modality&lt;/strong>, for the feature-poor cases where vision alone degrades.&lt;/li>
&lt;/ul>
&lt;h2 id="stack">Stack&lt;/h2>
&lt;p>Kimera-VIO (MIT-SPARK), GTSAM, ROS, Docker, Python, matplotlib. Datasets: EuRoC MAV V1_01–V1_03; self-collected RealSense D455 stereo + IMU.&lt;/p></description></item><item><title>Semantic Geometric SLAM (SG-SLAM) in Dynamic Scenes</title><link>https://juttu-s.github.io/saikiran_juttu.github.io/project/sg-slam/</link><pubDate>Fri, 19 Apr 2024 00:00:00 +0000</pubDate><guid>https://juttu-s.github.io/saikiran_juttu.github.io/project/sg-slam/</guid><description>&lt;p>Feature-based visual SLAM assumes the world holds still. It doesn&amp;rsquo;t. When someone walks through the frame, their features get matched across keyframes like any other, and the optimiser dutifully fits a camera trajectory to a person who was never part of the scene geometry. On the TUM &lt;code>fr3/walking_*&lt;/code> sequences this is not a marginal degradation — ORB-SLAM2&amp;rsquo;s absolute trajectory error goes to roughly half a metre.&lt;/p>
&lt;p>This project reimplements &lt;strong>SG-SLAM&lt;/strong> (&lt;a href="https://ieeexplore.ieee.org/document/10018238" target="_blank" rel="noopener">Cheng, Sun, Zhang &amp;amp; Zhang, &lt;em>IEEE TIM&lt;/em> vol. 72, 2023&lt;/a>), which addresses this by adding two parallel threads to ORB-SLAM2 — one for object detection, one for semantic mapping — and a feature-rejection stage in the tracking thread that consults both geometry and semantics before deciding what to keep.&lt;/p>
&lt;p>
&lt;figure >
&lt;div class="flex justify-center ">
&lt;div class="w-100" >&lt;img alt="SG-SLAM running on a live RGB-D stream: ORB features tracked on the static scene while a person walks through the frame, the keyframe graph, and the semantically labelled point cloud in RViz" srcset="
/saikiran_juttu.github.io/project/sg-slam/system_hu9625658651780549619.webp 400w,
/saikiran_juttu.github.io/project/sg-slam/system_hu10093939004686469362.webp 760w,
/saikiran_juttu.github.io/project/sg-slam/system_hu15078296984376168334.webp 1200w"
src="https://juttu-s.github.io/saikiran_juttu.github.io/saikiran_juttu.github.io/project/sg-slam/system_hu9625658651780549619.webp"
width="760"
height="422"
loading="lazy" data-zoomable />&lt;/div>
&lt;/div>&lt;/figure>
&lt;em>The three threads running together. Left: the live frame — note the green ORB features sit on desks, walls and shelving, not on the person. Bottom: the reconstructed point cloud with detected objects labelled and localised.&lt;/em>&lt;/p>
&lt;hr>
&lt;h2 id="why-not-just-mask-out-the-people">Why not just mask out the people&lt;/h2>
&lt;p>The obvious approach is to run a detector, draw boxes around every &lt;em>a priori&lt;/em> dynamic class, and discard whatever falls inside. That fails in both directions, and SG-SLAM&amp;rsquo;s design is a direct response to each failure:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Things move that aren&amp;rsquo;t in a box.&lt;/strong> A detector trained on 20 classes will miss a swinging door, a rolling cart, a shadow. Masking gives you no protection outside the boxes it drew.&lt;/li>
&lt;li>&lt;strong>Things in boxes aren&amp;rsquo;t always moving.&lt;/strong> A parked chair, an empty sofa, a person sitting perfectly still — all get classified dynamic, all get thrown away. In a cluttered indoor scene that can mean discarding the best-textured features you have.&lt;/li>
&lt;/ul>
&lt;h2 id="the-rejection-criterion">The rejection criterion&lt;/h2>
&lt;p>The mechanism that avoids both is worth reading closely, because it is subtler than a mask. Reproduced from the paper&amp;rsquo;s Algorithm 1:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">Input: Previous frame F1, current frame F2
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> Feature points P1 (previous), P2 (current)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> Standard empirical threshold e_std
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">Output: Set S of static feature points in the current frame
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> 1: P1 = CalcOpticalFlowPyrLK(F2, F1, P2)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> 2: Remove matched pairs at image edges or with large appearance variation
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> 3: F = FindFundamentalMat(P2, P1, 7-point method with RANSAC)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> 4: for each matched pair (p1, p2) in (P1, P2) do
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> 5: if DynamicObjectsExist and IsInDynamicRegion(p2) then
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> 6: if CalcEpiLineDistance(p2, p1, F) * GetDynamicWeightValue(p2) &amp;lt; e_std then
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> 7: append p2 to S
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> 8: end if
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> 9: else
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">10: if CalcEpiLineDistance(p2, p1, F) &amp;lt; e_std then
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">11: append p2 to S
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">12: end if
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">13: end if
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">14: end for
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>There is &lt;strong>one&lt;/strong> threshold, &lt;code>e_std&lt;/code>, and it is compared against a point-to-epipolar-line distance in both branches. The difference is that inside a detected region (line 6) that distance is first &lt;strong>multiplied&lt;/strong> by the object class&amp;rsquo;s dynamic weight.&lt;/p>
&lt;p>So semantics act as a per-class multiplier on the geometric residual, not as a veto. A high-weight class like a person has its residual inflated, so it gets rejected on much weaker geometric evidence than a low-weight class like a chair. But a genuinely stationary object still passes — its epipolar distance is near zero, and scaling near-zero by three is still near-zero. Meanwhile line 10 runs everywhere the detector saw nothing, so unmodelled motion is still caught geometrically.&lt;/p>
&lt;p>Two implementation details that are easy to miss: the optical flow at line 1 runs &lt;strong>backwards&lt;/strong>, tracking the current frame&amp;rsquo;s points into the previous frame to reconstruct correspondences, and the fundamental matrix uses the &lt;strong>seven-point&lt;/strong> method inside RANSAC rather than the more common normalised eight-point variant.&lt;/p>
&lt;h2 id="detector">Detector&lt;/h2>
&lt;p>Detection runs an &lt;strong>SSD with a MobileNetV3 backbone under NCNN&lt;/strong>, Tencent&amp;rsquo;s mobile CPU inference framework. That choice is what makes the real-time claim plausible without a GPU in the loop — the whole point of SG-SLAM over heavier semantic-SLAM systems is that it targets mobile platforms. The tracking thread computes its geometric quantities, blocks on the detector&amp;rsquo;s 2-D result for that frame, then runs rejection and tracks; only surviving features reach local mapping, loop closing and full bundle adjustment, all of which are unmodified ORB-SLAM2.&lt;/p>
&lt;div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;align-items:start">
&lt;div>
&lt;p>
&lt;figure >
&lt;div class="flex justify-center ">
&lt;div class="w-100" >&lt;img alt="Detection overlay with bounding boxes and class confidences" srcset="
/saikiran_juttu.github.io/project/sg-slam/detection_hu9192830305904395551.webp 400w,
/saikiran_juttu.github.io/project/sg-slam/detection_hu10414967818364573976.webp 760w,
/saikiran_juttu.github.io/project/sg-slam/detection_hu7677053416326043211.webp 1200w"
src="https://juttu-s.github.io/saikiran_juttu.github.io/saikiran_juttu.github.io/project/sg-slam/detection_hu9192830305904395551.webp"
width="368"
height="226"
loading="lazy" data-zoomable />&lt;/div>
&lt;/div>&lt;/figure>
&lt;em>Detection on a live frame.&lt;/em>&lt;/p>
&lt;/div>
&lt;div>
&lt;p>
&lt;figure >
&lt;div class="flex justify-center ">
&lt;div class="w-100" >&lt;img alt="Semantic object map with per-object 3D coordinates in RViz" srcset="
/saikiran_juttu.github.io/project/sg-slam/semantic-map_hu10489737656378413939.webp 400w,
/saikiran_juttu.github.io/project/sg-slam/semantic-map_hu13843709679552535452.webp 760w,
/saikiran_juttu.github.io/project/sg-slam/semantic-map_hu13383796692928516754.webp 1200w"
src="https://juttu-s.github.io/saikiran_juttu.github.io/saikiran_juttu.github.io/project/sg-slam/semantic-map_hu10489737656378413939.webp"
width="368"
height="233"
loading="lazy" data-zoomable />&lt;/div>
&lt;/div>&lt;/figure>
&lt;em>Objects localised in 3-D with class labels.&lt;/em>&lt;/p>
&lt;/div>
&lt;/div>
&lt;p>The semantic mapping thread fuses the 2-D detections with per-keyframe point clouds generated from the depth images and camera poses, then extracts each object&amp;rsquo;s position and extent into a 3-D semantic object database. That database, a global OctoMap and the camera poses are all published over ROS for RViz — which is the real difference from plain ORB-SLAM2, whose output is a sparse cloud with no idea what anything is.&lt;/p>
&lt;hr>
&lt;h2 id="results">Results&lt;/h2>
&lt;p>Benchmark figures below are the published results from Cheng et al., reproduced here rather than independently measured. Metric is &lt;strong>ATE RMSE in metres&lt;/strong> — these are error &lt;em>reductions&lt;/em>, not accuracy gains.&lt;/p>
&lt;h3 id="tum-rgb-d">TUM RGB-D&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Sequence&lt;/th>
&lt;th>Dynamics&lt;/th>
&lt;th>ORB-SLAM2&lt;/th>
&lt;th>SG-SLAM&lt;/th>
&lt;th>Reduction&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>fr3/walking_static&lt;/code>&lt;/td>
&lt;td>high&lt;/td>
&lt;td>0.4032&lt;/td>
&lt;td>0.0079&lt;/td>
&lt;td>&lt;strong>98.03%&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>fr3/walking_xyz&lt;/code>&lt;/td>
&lt;td>high&lt;/td>
&lt;td>0.6826&lt;/td>
&lt;td>0.0171&lt;/td>
&lt;td>&lt;strong>97.50%&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>fr3/walking_rpy&lt;/code>&lt;/td>
&lt;td>high&lt;/td>
&lt;td>0.5396&lt;/td>
&lt;td>0.0326&lt;/td>
&lt;td>&lt;strong>93.95%&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>fr3/walking_halfsphere&lt;/code>&lt;/td>
&lt;td>high&lt;/td>
&lt;td>0.4462&lt;/td>
&lt;td>0.0309&lt;/td>
&lt;td>&lt;strong>93.07%&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>fr3/sitting_static&lt;/code>&lt;/td>
&lt;td>low&lt;/td>
&lt;td>0.0087&lt;/td>
&lt;td>0.0060&lt;/td>
&lt;td>&lt;strong>31.03%&lt;/strong>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h3 id="bonn-rgb-d-dynamic-9-sequences">Bonn RGB-D Dynamic (9 sequences)&lt;/h3>
&lt;p>Best and worst of the set: &lt;code>synchronous2&lt;/code> improves 1.4069 → 0.0164 m (&lt;strong>98.83%&lt;/strong>), while &lt;code>synchronous&lt;/code> — nominally the same scene, different take — only reaches 1.1411 → 0.3262 m (&lt;strong>71.41%&lt;/strong>). The &lt;code>moving_nonobstructing_box&lt;/code> pair land around 71–79%, the &lt;code>crowd&lt;/code> and &lt;code>person_tracking&lt;/code> sequences 93–97%.&lt;/p>
&lt;h3 id="reading-the-numbers-honestly">Reading the numbers honestly&lt;/h3>
&lt;p>Two things about this table are worth saying out loud, because they&amp;rsquo;re the parts a results summary usually hides.&lt;/p>
&lt;p>&lt;strong>The 31% on &lt;code>sitting_static&lt;/code> isn&amp;rsquo;t a weak result, it&amp;rsquo;s the control.&lt;/strong> That sequence is low-dynamic — a person seated, barely moving. There is almost nothing for dynamic-feature rejection to remove, so the gain collapses to a third of what the walking sequences show. The rotational-drift table is starker still: 7.99% on the same sequence. That&amp;rsquo;s the expected shape of the result, and it&amp;rsquo;s evidence the mechanism is doing what it claims rather than just globally discarding features.&lt;/p>
&lt;p>&lt;strong>RMSE improves far more than the median does.&lt;/strong> On &lt;code>walking_static&lt;/code> the ATE RMSE drops 98% while the RPE median improvements sit at 43–53%. The gain is concentrated in &lt;em>catastrophic&lt;/em> frames — the ones where a person crossing the view wrecks the pose estimate entirely — not in the typical frame. That&amp;rsquo;s the right thing for a SLAM system to fix, since a single badly-corrupted keyframe propagates into the map, but it does mean &amp;ldquo;98% better&amp;rdquo; describes the tail, not the average.&lt;/p>
&lt;hr>
&lt;h2 id="where-it-breaks">Where it breaks&lt;/h2>
&lt;p>The failure mode falls straight out of Algorithm 1, and the paper is upfront about it: &lt;strong>an object moving along the epipolar line direction is invisible to this test.&lt;/strong> Its features produce a point-to-epipolar-line distance of approximately zero despite genuinely moving, so line 10 accepts them as static. The semantic branch doesn&amp;rsquo;t save you either — multiplying a near-zero residual by a dynamic weight still clears the threshold. In practice that means someone walking directly toward or away from the camera is much harder to reject than someone crossing the view.&lt;/p>
&lt;p>The other open item is semantic map precision. Object extents come from thresholding a depth-derived point cloud inside a 2-D box, which is coarse — good enough to say &amp;ldquo;there is a monitor at roughly here&amp;rdquo;, not good enough for manipulation.&lt;/p>
&lt;h2 id="stack">Stack&lt;/h2>
&lt;p>C++ (ORB-SLAM2 core), Python, Ubuntu 18.04, ROS Melodic, NCNN, OpenCV, OctoMap, RViz. Evaluated on TUM RGB-D and the Bonn RGB-D Dynamic dataset.&lt;/p></description></item><item><title>Automated Insect Leg Labeling using DeepLabCut</title><link>https://juttu-s.github.io/saikiran_juttu.github.io/project/automated-insect-leg-labeling/</link><pubDate>Mon, 15 Apr 2024 00:00:00 +0000</pubDate><guid>https://juttu-s.github.io/saikiran_juttu.github.io/project/automated-insect-leg-labeling/</guid><description>&lt;p>This project proposes a robust and scalable method for automating insect leg labeling using &lt;strong>image processing, feature detection, and clustering&lt;/strong>, enabling seamless integration with &lt;strong>DeepLabCut&lt;/strong> for behavioral analysis.&lt;/p>
&lt;hr>
&lt;h2 id="methodology-overview">Methodology Overview&lt;/h2>
&lt;h3 id="1-data-collection">1. Data Collection&lt;/h3>
&lt;ul>
&lt;li>Captured insect locomotion data using a &lt;strong>ServoSphere&lt;/strong> robot equipped with omni-wheels and a high-speed camera.&lt;/li>
&lt;li>The camera tracked an insect (ant) placed atop the rotating sphere.&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h3 id="2-preprocessing-pipeline">2. Preprocessing Pipeline&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Grayscale conversion&lt;/strong> and &lt;strong>Gaussian blur&lt;/strong> for noise reduction&lt;/li>
&lt;li>&lt;strong>Canny Edge Detection&lt;/strong> for edge enhancement&lt;/li>
&lt;li>&lt;strong>Binary thresholding&lt;/strong> and &lt;strong>morphological operations&lt;/strong> for skeletonization&lt;/li>
&lt;/ul>
&lt;p>
&lt;figure >
&lt;div class="flex justify-center ">
&lt;div class="w-100" >&lt;img alt="Canny Edge Result" srcset="
/saikiran_juttu.github.io/project/automated-insect-leg-labeling/canny_hu11275747406885085122.webp 400w,
/saikiran_juttu.github.io/project/automated-insect-leg-labeling/canny_hu15745542599272552929.webp 760w,
/saikiran_juttu.github.io/project/automated-insect-leg-labeling/canny_hu3473078471050911037.webp 1200w"
src="https://juttu-s.github.io/saikiran_juttu.github.io/saikiran_juttu.github.io/project/automated-insect-leg-labeling/canny_hu11275747406885085122.webp"
width="555"
height="350"
loading="lazy" data-zoomable />&lt;/div>
&lt;/div>&lt;/figure>
&lt;br>
&lt;em>Figure: Output after applying Canny edge detection.&lt;/em>&lt;/p>
&lt;hr>
&lt;h3 id="3-feature-extraction">3. Feature Extraction&lt;/h3>
&lt;ul>
&lt;li>Applied &lt;strong>Shi-Tomasi Corner Detection&lt;/strong> on Canny edges for precise joint detection&lt;/li>
&lt;li>Robust to noise and sensitive to detailed motion features&lt;/li>
&lt;/ul>
&lt;p>
&lt;figure >
&lt;div class="flex justify-center ">
&lt;div class="w-100" >&lt;img alt="Shi-Tomasi Detection" srcset="
/saikiran_juttu.github.io/project/automated-insect-leg-labeling/shi_tomasi_hu3655572418342755069.webp 400w,
/saikiran_juttu.github.io/project/automated-insect-leg-labeling/shi_tomasi_hu14103565545081466617.webp 760w,
/saikiran_juttu.github.io/project/automated-insect-leg-labeling/shi_tomasi_hu4890327641649225453.webp 1200w"
src="https://juttu-s.github.io/saikiran_juttu.github.io/saikiran_juttu.github.io/project/automated-insect-leg-labeling/shi_tomasi_hu3655572418342755069.webp"
width="555"
height="350"
loading="lazy" data-zoomable />&lt;/div>
&lt;/div>&lt;/figure>
&lt;br>
&lt;em>Figure: Shi-Tomasi corner detection highlights potential joint features.&lt;/em>&lt;/p>
&lt;hr>
&lt;h3 id="4-body-removal">4. Body Removal&lt;/h3>
&lt;ul>
&lt;li>Used &lt;strong>Zhang-Suen thinning&lt;/strong> for skeleton extraction&lt;/li>
&lt;li>Applied &lt;strong>connected component labeling&lt;/strong> to segment body parts&lt;/li>
&lt;li>Removed body via &lt;strong>template matching&lt;/strong>&lt;/li>
&lt;/ul>
&lt;p>
&lt;figure >
&lt;div class="flex justify-center ">
&lt;div class="w-100" >&lt;img alt="Skeleton Extraction" srcset="
/saikiran_juttu.github.io/project/automated-insect-leg-labeling/skeleton_extraction_hu7723478630954022702.webp 400w,
/saikiran_juttu.github.io/project/automated-insect-leg-labeling/skeleton_extraction_hu1654188756838549732.webp 760w,
/saikiran_juttu.github.io/project/automated-insect-leg-labeling/skeleton_extraction_hu4799263873215453537.webp 1200w"
src="https://juttu-s.github.io/saikiran_juttu.github.io/saikiran_juttu.github.io/project/automated-insect-leg-labeling/skeleton_extraction_hu7723478630954022702.webp"
width="553"
height="355"
loading="lazy" data-zoomable />&lt;/div>
&lt;/div>&lt;/figure>
&lt;br>
&lt;em>Figure: Skeleton representation using Zhang-Suen thinning.&lt;/em>&lt;/p>
&lt;p>
&lt;figure >
&lt;div class="flex justify-center ">
&lt;div class="w-100" >&lt;img alt="Connected Components" srcset="
/saikiran_juttu.github.io/project/automated-insect-leg-labeling/connected_components_hu7217986797033856015.webp 400w,
/saikiran_juttu.github.io/project/automated-insect-leg-labeling/connected_components_hu12195037151038958192.webp 760w,
/saikiran_juttu.github.io/project/automated-insect-leg-labeling/connected_components_hu4918036970471170597.webp 1200w"
src="https://juttu-s.github.io/saikiran_juttu.github.io/saikiran_juttu.github.io/project/automated-insect-leg-labeling/connected_components_hu7217986797033856015.webp"
width="545"
height="378"
loading="lazy" data-zoomable />&lt;/div>
&lt;/div>&lt;/figure>
&lt;br>
&lt;em>Figure: Connected component labeling for body part isolation.&lt;/em>&lt;/p>
&lt;p>
&lt;figure >
&lt;div class="flex justify-center ">
&lt;div class="w-100" >&lt;img alt="Body Template" srcset="
/saikiran_juttu.github.io/project/automated-insect-leg-labeling/template_hu11659726123555705027.webp 400w,
/saikiran_juttu.github.io/project/automated-insect-leg-labeling/template_hu18209107862982516437.webp 760w,
/saikiran_juttu.github.io/project/automated-insect-leg-labeling/template_hu2884419823242467532.webp 1200w"
src="https://juttu-s.github.io/saikiran_juttu.github.io/saikiran_juttu.github.io/project/automated-insect-leg-labeling/template_hu11659726123555705027.webp"
width="499"
height="353"
loading="lazy" data-zoomable />&lt;/div>
&lt;/div>&lt;/figure>
&lt;br>
&lt;em>Figure: Template matching used to isolate and remove the body region.&lt;/em>&lt;/p>
&lt;hr>
&lt;h3 id="5-leg-detection">5. Leg Detection&lt;/h3>
&lt;ul>
&lt;li>Remaining features correspond to legs&lt;/li>
&lt;li>Calculated angles of features w.r.t. body centroid&lt;/li>
&lt;/ul>
&lt;p>
&lt;figure >
&lt;div class="flex justify-center ">
&lt;div class="w-100" >&lt;img alt="Detected Leg Features" srcset="
/saikiran_juttu.github.io/project/automated-insect-leg-labeling/leg_features_hu7341448728120080587.webp 400w,
/saikiran_juttu.github.io/project/automated-insect-leg-labeling/leg_features_hu18101909398949920466.webp 760w,
/saikiran_juttu.github.io/project/automated-insect-leg-labeling/leg_features_hu14108854493791996505.webp 1200w"
src="https://juttu-s.github.io/saikiran_juttu.github.io/saikiran_juttu.github.io/project/automated-insect-leg-labeling/leg_features_hu7341448728120080587.webp"
width="489"
height="320"
loading="lazy" data-zoomable />&lt;/div>
&lt;/div>&lt;/figure>
&lt;br>
&lt;em>Figure: Extracted leg features post body removal.&lt;/em>&lt;/p>
&lt;p>
&lt;figure >
&lt;div class="flex justify-center ">
&lt;div class="w-100" >&lt;img alt="Feature Angles" srcset="
/saikiran_juttu.github.io/project/automated-insect-leg-labeling/angles_hu911305775039293742.webp 400w,
/saikiran_juttu.github.io/project/automated-insect-leg-labeling/angles_hu16264059692635278634.webp 760w,
/saikiran_juttu.github.io/project/automated-insect-leg-labeling/angles_hu12389778951175088630.webp 1200w"
src="https://juttu-s.github.io/saikiran_juttu.github.io/saikiran_juttu.github.io/project/automated-insect-leg-labeling/angles_hu911305775039293742.webp"
width="496"
height="338"
loading="lazy" data-zoomable />&lt;/div>
&lt;/div>&lt;/figure>
&lt;br>
&lt;em>Figure: Angle estimation of each leg with respect to the centroid.&lt;/em>&lt;/p>
&lt;hr>
&lt;h3 id="6-clustering--tip-detection">6. Clustering &amp;amp; Tip Detection&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>KMeans (K=6)&lt;/strong> clusters features into 6 legs&lt;/li>
&lt;li>Furthest feature in each cluster = leg tip&lt;/li>
&lt;/ul>
&lt;p>
&lt;figure >
&lt;div class="flex justify-center ">
&lt;div class="w-100" >&lt;img alt="KMeans Clustering" srcset="
/saikiran_juttu.github.io/project/automated-insect-leg-labeling/kmeans_hu2991777993052901984.webp 400w,
/saikiran_juttu.github.io/project/automated-insect-leg-labeling/kmeans_hu1537055730035741828.webp 760w,
/saikiran_juttu.github.io/project/automated-insect-leg-labeling/kmeans_hu8003083638116927705.webp 1200w"
src="https://juttu-s.github.io/saikiran_juttu.github.io/saikiran_juttu.github.io/project/automated-insect-leg-labeling/kmeans_hu2991777993052901984.webp"
width="556"
height="406"
loading="lazy" data-zoomable />&lt;/div>
&lt;/div>&lt;/figure>
&lt;br>
&lt;em>Figure: KMeans clustering of features into 6 leg regions.&lt;/em>&lt;/p>
&lt;hr>
&lt;h3 id="7-integration-with-deeplabcut">7. Integration with DeepLabCut&lt;/h3>
&lt;ul>
&lt;li>Created &lt;code>.h5&lt;/code> files with clustered keypoints&lt;/li>
&lt;li>Trained DeepLabCut on auto-labeled dataset&lt;/li>
&lt;li>Achieved performance near manual labeling&lt;/li>
&lt;/ul>
&lt;p>
&lt;figure >
&lt;div class="flex justify-center ">
&lt;div class="w-100" >&lt;img alt="DLC Integration" srcset="
/saikiran_juttu.github.io/project/automated-insect-leg-labeling/integration_hu13320621738235640093.webp 400w,
/saikiran_juttu.github.io/project/automated-insect-leg-labeling/integration_hu5511305675759056958.webp 760w,
/saikiran_juttu.github.io/project/automated-insect-leg-labeling/integration_hu10805202269737531730.webp 1200w"
src="https://juttu-s.github.io/saikiran_juttu.github.io/saikiran_juttu.github.io/project/automated-insect-leg-labeling/integration_hu13320621738235640093.webp"
width="671"
height="615"
loading="lazy" data-zoomable />&lt;/div>
&lt;/div>&lt;/figure>
&lt;br>
&lt;em>Figure: Final annotated labels used with DeepLabCut.&lt;/em>&lt;/p>
&lt;hr>
&lt;h2 id="experimental-results">Experimental Results&lt;/h2>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Leg&lt;/th>
&lt;th>TP (Auto)&lt;/th>
&lt;th>FP (Auto)&lt;/th>
&lt;th>TP (Manual)&lt;/th>
&lt;th>FP (Manual)&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>1&lt;/td>
&lt;td>95&lt;/td>
&lt;td>2&lt;/td>
&lt;td>98&lt;/td>
&lt;td>1&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>2&lt;/td>
&lt;td>90&lt;/td>
&lt;td>3&lt;/td>
&lt;td>95&lt;/td>
&lt;td>2&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>3&lt;/td>
&lt;td>85&lt;/td>
&lt;td>4&lt;/td>
&lt;td>90&lt;/td>
&lt;td>2&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>4&lt;/td>
&lt;td>80&lt;/td>
&lt;td>3&lt;/td>
&lt;td>85&lt;/td>
&lt;td>3&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>5&lt;/td>
&lt;td>75&lt;/td>
&lt;td>5&lt;/td>
&lt;td>80&lt;/td>
&lt;td>4&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>6&lt;/td>
&lt;td>70&lt;/td>
&lt;td>3&lt;/td>
&lt;td>75&lt;/td>
&lt;td>3&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;blockquote>
&lt;p>&lt;em>Confusion matrix comparing auto-labeled vs manually labeled results. Accuracy slightly lower, but performance is consistent and scalable.&lt;/em>&lt;/p>
&lt;/blockquote>
&lt;hr>
&lt;h2 id="key-techniques">Key Techniques&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Canny Edge Detection&lt;/strong>&lt;/li>
&lt;li>&lt;strong>Shi-Tomasi GFTT&lt;/strong>&lt;/li>
&lt;li>&lt;strong>Template Matching&lt;/strong>&lt;/li>
&lt;li>&lt;strong>KMeans Clustering&lt;/strong>&lt;/li>
&lt;li>&lt;strong>Zhang-Suen Thinning&lt;/strong>&lt;/li>
&lt;li>&lt;strong>DeepLabCut integration&lt;/strong>&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="insights--future-work">Insights &amp;amp; Future Work&lt;/h2>
&lt;ul>
&lt;li>Automation significantly reduced manual effort&lt;/li>
&lt;li>High reproducibility across ant datasets&lt;/li>
&lt;li>KMeans produced sharper clusters than Ensemble KMeans&lt;/li>
&lt;li>Future work may explore deep learning-based leg segmentation and adaptive clustering strategies&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="resources">Resources&lt;/h2>
&lt;ul>
&lt;li>📄 &lt;a href="report.pdf">Project Report (PDF)&lt;/a>&lt;/li>
&lt;li>🔗 &lt;a href="https://github.com/juttu-s/Pattern-Recognition-and-Computer-Vision/tree/main/Automated%20Insect%20Leg%20Labeling" target="_blank" rel="noopener">GitHub Repository&lt;/a>&lt;/li>
&lt;/ul>
&lt;hr>
&lt;p>This approach provides a &lt;strong>generalizable and scalable method&lt;/strong> for anatomical labeling in biological research and can be extended to other multi-limbed species or anatomical joints.&lt;/p></description></item><item><title>Dead Reckoning with IMU &amp; GPS in Vehicle Navigation</title><link>https://juttu-s.github.io/saikiran_juttu.github.io/project/dead-reckoning/</link><pubDate>Tue, 09 Apr 2024 00:00:00 +0000</pubDate><guid>https://juttu-s.github.io/saikiran_juttu.github.io/project/dead-reckoning/</guid><description>&lt;p>Take away the GPS fix and a vehicle has to work out where it is from acceleration and rotation alone. This project does that end to end on real data — instrument a car, calibrate the magnetometer properly, fuse a heading estimate, integrate forward velocity, and reconstruct the trajectory — then compares the result against GPS to find out how long dead reckoning actually survives.&lt;/p>
&lt;p>The short answer: &lt;strong>about three minutes.&lt;/strong> After that it is off by the length of a city block.&lt;/p>
&lt;p>Coursework for &lt;strong>EECE5554: Robotics Sensing and Navigation&lt;/strong> at Northeastern.&lt;/p>
&lt;hr>
&lt;h2 id="setup">Setup&lt;/h2>
&lt;p>A &lt;strong>VectorNav VN-100&lt;/strong> IMU mounted inside the vehicle in a custom holder, deliberately aligned so its &lt;strong>x-axis points along the vehicle&amp;rsquo;s forward direction&lt;/strong> and the unit sits level. That alignment is doing real work later — it&amp;rsquo;s what lets &lt;code>accel_x&lt;/code> be treated as forward acceleration and &lt;code>accel_y&lt;/code> as lateral without a rotation. A magnetic-mount GPS puck went on the roof, both feeding one laptop over ROS.&lt;/p>
&lt;p>Two datasets:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Circles&lt;/strong> — five laps of the Ruggles station circle, recorded purely to excite the magnetometer through a full 360° of heading. This is calibration data, not navigation data.&lt;/li>
&lt;li>&lt;strong>Driving&lt;/strong> — a 2–3 km route around Boston with at least ten turns, roughly 1300 s, routed to avoid tunnels and underpasses so the GPS reference stays continuous.&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="magnetometer-calibration">Magnetometer calibration&lt;/h2>
&lt;p>An uncalibrated magnetometer driven in a circle should trace a circle. It doesn&amp;rsquo;t, and the two ways it fails are visually distinguishable from the same scatter plot:&lt;/p>
&lt;p>
&lt;figure >
&lt;div class="flex justify-center ">
&lt;div class="w-100" >&lt;img alt="Magnetometer X-Y scatter before and after calibration — the measured ring offset from the origin, the calibrated ring centred on it" srcset="
/saikiran_juttu.github.io/project/dead-reckoning/magnetometer-calibration_hu6564607695943048432.webp 400w,
/saikiran_juttu.github.io/project/dead-reckoning/magnetometer-calibration_hu4157646788420770913.webp 760w,
/saikiran_juttu.github.io/project/dead-reckoning/magnetometer-calibration_hu17823870522234568360.webp 1200w"
src="https://juttu-s.github.io/saikiran_juttu.github.io/saikiran_juttu.github.io/project/dead-reckoning/magnetometer-calibration_hu6564607695943048432.webp"
width="760"
height="573"
loading="lazy" data-zoomable />&lt;/div>
&lt;/div>&lt;/figure>
&lt;em>Five laps of the Ruggles circle. Orange is raw, blue is corrected.&lt;/em>&lt;/p>
&lt;ul>
&lt;li>&lt;strong>The ring is off-centre&lt;/strong> → &lt;strong>hard iron&lt;/strong> distortion. A constant field offset from permanent magnets, ferrous structure or current-carrying conductors near the sensor. The raw ring here sits up and to the right of the origin, biased by roughly 0.27 G in x and 0.22 G in y. Correction is a per-axis bias subtraction that recentres the cluster.&lt;/li>
&lt;li>&lt;strong>The ring is deformed&lt;/strong> → &lt;strong>soft iron&lt;/strong> distortion. Non-uniform scaling and rotation from nearby ferrous material. You can see it in both rings as a distinct kink near the +x side — the shape isn&amp;rsquo;t circular and no amount of translation fixes that. Correction is a matrix that scales and rotates the data back toward a circle.&lt;/li>
&lt;/ul>
&lt;p>The parameters come from a least-squares ellipse fit to the circle-driving data, applied as bias subtraction followed by the correction matrix. Being able to read &lt;em>which&lt;/em> distortion you have off the plot before fixing either is the useful part of this exercise — the two have different causes and different remedies, and the geometry tells you which you&amp;rsquo;re looking at.&lt;/p>
&lt;h2 id="heading">Heading&lt;/h2>
&lt;p>Neither heading source is usable alone. The magnetometer gives absolute heading but is noisy and sensitive to the vehicle&amp;rsquo;s own electrical and magnetic environment. The gyro is clean over short intervals but integrates its bias without bound — the raw integrated yaw here climbs to about &lt;strong>+405°&lt;/strong> by t ≈ 560 s and plateaus near 300°, which is meaningless as an absolute heading.&lt;/p>
&lt;p>A complementary filter takes the half of each signal that&amp;rsquo;s trustworthy:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>0.5 Hz low-pass&lt;/strong> on magnetometer yaw — keeps the slow-moving absolute reference, discards the noise&lt;/li>
&lt;li>&lt;strong>0.1 Hz high-pass&lt;/strong> on gyro yaw — keeps short-term rate fidelity, discards the drift&lt;/li>
&lt;li>blended with coefficient &lt;strong>0.98&lt;/strong>&lt;/li>
&lt;/ul>
&lt;p>
&lt;figure >
&lt;div class="flex justify-center ">
&lt;div class="w-100" >&lt;img alt="Four-panel comparison: low-passed magnetometer yaw, high-passed gyro yaw, complementary filter output, and the VN-100&amp;rsquo;s own onboard heading estimate" srcset="
/saikiran_juttu.github.io/project/dead-reckoning/heading-fusion_hu16052623581733354419.webp 400w,
/saikiran_juttu.github.io/project/dead-reckoning/heading-fusion_hu9034352427183490476.webp 760w,
/saikiran_juttu.github.io/project/dead-reckoning/heading-fusion_hu864362406907695216.webp 1200w"
src="https://juttu-s.github.io/saikiran_juttu.github.io/saikiran_juttu.github.io/project/dead-reckoning/heading-fusion_hu16052623581733354419.webp"
width="760"
height="372"
loading="lazy" data-zoomable />&lt;/div>
&lt;/div>&lt;/figure>
&lt;em>The two filtered branches, the fused output, and the VN-100&amp;rsquo;s internal estimate for comparison.&lt;/em>&lt;/p>
&lt;hr>
&lt;h2 id="forward-velocity-and-the-number-that-gave-the-game-away">Forward velocity, and the number that gave the game away&lt;/h2>
&lt;p>Integrating raw accelerometer output produces nonsense. The raw forward-velocity trace peaks around &lt;strong>+35 m/s&lt;/strong> and dips to &lt;strong>−15 m/s&lt;/strong> — the car did neither. That&amp;rsquo;s accelerometer bias being integrated into an unbounded ramp, plus road vibration and the gravity component that any unnoticed slope injects into the forward axis.&lt;/p>
&lt;p>Two corrections bring it into physical range: a high-pass filter on forward acceleration to strip the low-frequency bias, then clamps — negative velocities to zero, plus an upper bound. That gets the adjusted trace to a plausible &lt;strong>0–11 m/s&lt;/strong>, against GPS-derived velocity peaking at 12.7 m/s.&lt;/p>
&lt;p>
&lt;figure >
&lt;div class="flex justify-center ">
&lt;div class="w-100" >&lt;img alt="Raw integrated forward velocity against the filtered and clamped version" srcset="
/saikiran_juttu.github.io/project/dead-reckoning/forward-velocity_hu6766549600153078957.webp 400w,
/saikiran_juttu.github.io/project/dead-reckoning/forward-velocity_hu2268536860800196880.webp 760w,
/saikiran_juttu.github.io/project/dead-reckoning/forward-velocity_hu229241971526026250.webp 1200w"
src="https://juttu-s.github.io/saikiran_juttu.github.io/saikiran_juttu.github.io/project/dead-reckoning/forward-velocity_hu6766549600153078957.webp"
width="760"
height="398"
loading="lazy" data-zoomable />&lt;/div>
&lt;/div>&lt;/figure>
&lt;em>Blue: integrate the raw signal and the car reaches 35 m/s and reverses. Orange: after high-pass filtering and clamping.&lt;/em>&lt;/p>
&lt;p>Then aligning the dead-reckoned track to GPS required scaling the IMU distances by &lt;strong>2.25×&lt;/strong>.&lt;/p>
&lt;p>That factor is the most informative number in the whole project, and it isn&amp;rsquo;t a calibration constant — it&amp;rsquo;s a symptom. A 2.25× scale error means the integrated velocity was systematically &lt;strong>less than half&lt;/strong> the true speed, and both corrections above are implicated. The high-pass filter that removed accelerometer bias also removed genuine low-frequency acceleration, because a car accelerating gently from a light and a slowly-drifting bias occupy the same part of the spectrum. And clamping negative velocity to zero doesn&amp;rsquo;t just discard drift — it &lt;strong>rectifies&lt;/strong> the signal, converting every real deceleration the drifting integrator pushed below zero into &amp;ldquo;stationary&amp;rdquo;. Distance travelled comes out short in both cases.&lt;/p>
&lt;p>&lt;strong>The filter that fixed the drift destroyed the scale.&lt;/strong> Getting both would need the bias estimated as a state rather than filtered out — which is the argument for a Kalman filter over hand-tuned Butterworths, and the direction I&amp;rsquo;d take this next.&lt;/p>
&lt;h2 id="trajectory">Trajectory&lt;/h2>
&lt;p>
&lt;figure >
&lt;div class="flex justify-center ">
&lt;div class="w-100" >&lt;img alt="Dead-reckoned trajectory beside the GPS ground track, both in Easting/Northing metres" srcset="
/saikiran_juttu.github.io/project/dead-reckoning/trajectories_hu12573546119952805973.webp 400w,
/saikiran_juttu.github.io/project/dead-reckoning/trajectories_hu11079378006867108752.webp 760w,
/saikiran_juttu.github.io/project/dead-reckoning/trajectories_hu14489892853533858604.webp 1200w"
src="https://juttu-s.github.io/saikiran_juttu.github.io/saikiran_juttu.github.io/project/dead-reckoning/trajectories_hu12573546119952805973.webp"
width="760"
height="347"
loading="lazy" data-zoomable />&lt;/div>
&lt;/div>&lt;/figure>
&lt;em>Dead-reckoned track (left) against GPS (right). The shape survives; the extent doesn&amp;rsquo;t.&lt;/em>&lt;/p>
&lt;p>Projecting forward velocity onto the fused heading and integrating gives the track above, aligned to GPS by matching start coordinates and the first straight segment.&lt;/p>
&lt;ul>
&lt;li>The two agree to within about &lt;strong>2 m for the first 150–200 s&lt;/strong>&lt;/li>
&lt;li>After that they diverge, reaching roughly &lt;strong>400 m&lt;/strong> of displacement error by the end of the ~2–3 km route&lt;/li>
&lt;/ul>
&lt;p>Dead reckoning worked best exactly where you&amp;rsquo;d expect — early, on straight, level road. It degraded with each successive turn, and the two causes are &lt;strong>gyro bias instability&lt;/strong> feeding heading error into every subsequent position update, and &lt;strong>unanticipated road slopes&lt;/strong> tilting gravity onto the forward axis where it gets integrated as acceleration.&lt;/p>
&lt;p>One honest wrinkle in the alignment: the IMU track was rotated 14° and the GPS track 25°, in opposite service of making the first straight segments agree. Rotating &lt;em>both&lt;/em> by &lt;em>different&lt;/em> amounts means the result no longer references true north — a single rotation of the difference would have been the defensible choice.&lt;/p>
&lt;h2 id="the-takeaway">The takeaway&lt;/h2>
&lt;p>There&amp;rsquo;s a usable operating envelope here and it&amp;rsquo;s narrow. Roughly &lt;strong>150–200 seconds of GPS outage&lt;/strong> is survivable at 2 m accuracy, which is enough to cross a tunnel or an urban canyon. It is nowhere near enough to navigate on, and the failure is not graceful — error grows superlinearly as heading error compounds. Any real system needs periodic absolute fixes, and the interesting engineering question is how sparse those fixes can be, not whether you need them.&lt;/p>
&lt;h2 id="stack">Stack&lt;/h2>
&lt;p>ROS (rosbag, sensor nodes), Python, SciPy (&lt;code>butter&lt;/code>/&lt;code>filtfilt&lt;/code>, least-squares fitting), matplotlib, UTM projection. Hardware: VectorNav VN-100, magnetic-mount GPS puck, Northeastern&amp;rsquo;s instrumented &amp;ldquo;nuance&amp;rdquo; vehicle.&lt;/p></description></item><item><title>Digit Recognition &amp; Transfer Learning with CNNs</title><link>https://juttu-s.github.io/saikiran_juttu.github.io/project/digit-recognition/</link><pubDate>Mon, 01 Apr 2024 00:00:00 +0000</pubDate><guid>https://juttu-s.github.io/saikiran_juttu.github.io/project/digit-recognition/</guid><description>&lt;p>This project focused on &lt;strong>designing, training, and analyzing convolutional neural networks (CNNs)&lt;/strong> for digit recognition and then &lt;strong>applying transfer learning&lt;/strong> to recognize Greek characters using PyTorch.&lt;/p>
&lt;hr>
&lt;h2 id="cnn-for-digit-recognition-mnist">CNN for Digit Recognition (MNIST)&lt;/h2>
&lt;p>We first built and trained a simple CNN model using the MNIST dataset:&lt;/p>
&lt;ul>
&lt;li>Two convolutional layers (5x5 filters)&lt;/li>
&lt;li>Max pooling (2x2) and dropout (0.5)&lt;/li>
&lt;li>Fully connected layer (50 nodes) + final FC with log-softmax (10 classes)&lt;/li>
&lt;li>Achieved &lt;strong>~98% test accuracy&lt;/strong>&lt;/li>
&lt;/ul>
&lt;p>
&lt;figure >
&lt;div class="flex justify-center ">
&lt;div class="w-100" >&lt;img alt="Sample MNIST Digits" srcset="
/saikiran_juttu.github.io/project/digit-recognition/firstsix_hu15902310905350978628.webp 400w,
/saikiran_juttu.github.io/project/digit-recognition/firstsix_hu10625707504812272563.webp 760w,
/saikiran_juttu.github.io/project/digit-recognition/firstsix_hu9923025005567100836.webp 1200w"
src="https://juttu-s.github.io/saikiran_juttu.github.io/saikiran_juttu.github.io/project/digit-recognition/firstsix_hu15902310905350978628.webp"
width="708"
height="400"
loading="lazy" data-zoomable />&lt;/div>
&lt;/div>&lt;/figure>
&lt;em>Figure: Example of first 6 MNIST test samples.&lt;/em>&lt;/p>
&lt;hr>
&lt;h2 id="network-architecture">Network Architecture&lt;/h2>
&lt;p>
&lt;figure >
&lt;div class="flex justify-center ">
&lt;div class="w-100" >&lt;img alt="CNN Diagram" srcset="
/saikiran_juttu.github.io/project/digit-recognition/NN_hu7544495459113416032.webp 400w,
/saikiran_juttu.github.io/project/digit-recognition/NN_hu11458655653262536510.webp 760w,
/saikiran_juttu.github.io/project/digit-recognition/NN_hu5398614519963812698.webp 1200w"
src="https://juttu-s.github.io/saikiran_juttu.github.io/saikiran_juttu.github.io/project/digit-recognition/NN_hu7544495459113416032.webp"
width="760"
height="509"
loading="lazy" data-zoomable />&lt;/div>
&lt;/div>&lt;/figure>
&lt;em>Figure: End-to-end architecture used for digit recognition.&lt;/em>&lt;/p>
&lt;hr>
&lt;h2 id="training-performance">Training Performance&lt;/h2>
&lt;ul>
&lt;li>Loss decreased steadily over batches/epochs.&lt;/li>
&lt;li>Test loss visualized periodically across training examples.&lt;/li>
&lt;/ul>
&lt;p>
&lt;figure >
&lt;div class="flex justify-center ">
&lt;div class="w-100" >&lt;img alt="Loss Curve" srcset="
/saikiran_juttu.github.io/project/digit-recognition/loss_hu2647141672864762400.webp 400w,
/saikiran_juttu.github.io/project/digit-recognition/loss_hu14298867483472384218.webp 760w,
/saikiran_juttu.github.io/project/digit-recognition/loss_hu3418306977125921203.webp 1200w"
src="https://juttu-s.github.io/saikiran_juttu.github.io/saikiran_juttu.github.io/project/digit-recognition/loss_hu2647141672864762400.webp"
width="760"
height="559"
loading="lazy" data-zoomable />&lt;/div>
&lt;/div>&lt;/figure>
&lt;em>Figure: Negative log-likelihood training and test loss.&lt;/em>&lt;/p>
&lt;hr>
&lt;h2 id="filter-visualization">Filter Visualization&lt;/h2>
&lt;ul>
&lt;li>First conv layer has 10 learned filters.&lt;/li>
&lt;li>Visualized using matplotlib to show edge/orientation detection.&lt;/li>
&lt;/ul>
&lt;p>
&lt;figure >
&lt;div class="flex justify-center ">
&lt;div class="w-100" >&lt;img alt="Filter Maps" srcset="
/saikiran_juttu.github.io/project/digit-recognition/filter_maps1_hu6510234662074493762.webp 400w,
/saikiran_juttu.github.io/project/digit-recognition/filter_maps1_hu11416344688562476833.webp 760w,
/saikiran_juttu.github.io/project/digit-recognition/filter_maps1_hu16678790348298768722.webp 1200w"
src="https://juttu-s.github.io/saikiran_juttu.github.io/saikiran_juttu.github.io/project/digit-recognition/filter_maps1_hu6510234662074493762.webp"
width="638"
height="469"
loading="lazy" data-zoomable />&lt;/div>
&lt;/div>&lt;/figure>
&lt;em>Figure: Learned 5x5 filters from first conv layer.&lt;/em>&lt;/p>
&lt;hr>
&lt;h2 id="transfer-learning-to-greek-letters">Transfer Learning to Greek Letters&lt;/h2>
&lt;ul>
&lt;li>Fine-tuned MNIST CNN to classify Greek characters: α, β, γ.&lt;/li>
&lt;li>Froze earlier layers, retrained final classifier head.&lt;/li>
&lt;li>Achieved &lt;strong>96–98% accuracy&lt;/strong> on unseen test samples.&lt;/li>
&lt;/ul>
&lt;p>
&lt;figure >
&lt;div class="flex justify-center ">
&lt;div class="w-100" >&lt;img alt="Greek Classification Output" srcset="
/saikiran_juttu.github.io/project/digit-recognition/greek_hu4305634218149458193.webp 400w,
/saikiran_juttu.github.io/project/digit-recognition/greek_hu16620829909298196167.webp 760w,
/saikiran_juttu.github.io/project/digit-recognition/greek_hu10198682355568399909.webp 1200w"
src="https://juttu-s.github.io/saikiran_juttu.github.io/saikiran_juttu.github.io/project/digit-recognition/greek_hu4305634218149458193.webp"
width="652"
height="391"
loading="lazy" data-zoomable />&lt;/div>
&lt;/div>&lt;/figure>
&lt;em>Figure: Correct classification of Greek letters using transfer learning.&lt;/em>&lt;/p>
&lt;hr>
&lt;h2 id="transfer-learning-training-error">Transfer Learning Training Error&lt;/h2>
&lt;ul>
&lt;li>Convergence observed over 100+ epochs.&lt;/li>
&lt;/ul>
&lt;p>
&lt;figure >
&lt;div class="flex justify-center ">
&lt;div class="w-100" >&lt;img alt="Greek Training Error Curve" srcset="
/saikiran_juttu.github.io/project/digit-recognition/greek_training_hu10070426372364170428.webp 400w,
/saikiran_juttu.github.io/project/digit-recognition/greek_training_hu13936645240799545862.webp 760w,
/saikiran_juttu.github.io/project/digit-recognition/greek_training_hu10173284984926363065.webp 1200w"
src="https://juttu-s.github.io/saikiran_juttu.github.io/saikiran_juttu.github.io/project/digit-recognition/greek_training_hu10070426372364170428.webp"
width="640"
height="508"
loading="lazy" data-zoomable />&lt;/div>
&lt;/div>&lt;/figure>
&lt;em>Figure: Error vs epoch for Greek classification.&lt;/em>&lt;/p>
&lt;hr>
&lt;h2 id="live-webcam-digit-recognition">Live Webcam Digit Recognition&lt;/h2>
&lt;ul>
&lt;li>Built a webcam-based interface using OpenCV&lt;/li>
&lt;li>Live input frames classified in real time using the trained CNN&lt;/li>
&lt;/ul>
&lt;p>
&lt;figure >
&lt;div class="flex justify-center ">
&lt;div class="w-100" >&lt;img alt="live webcam" srcset="
/saikiran_juttu.github.io/project/digit-recognition/live_hu16096523079256018570.webp 400w,
/saikiran_juttu.github.io/project/digit-recognition/live_hu4232723163901620511.webp 760w,
/saikiran_juttu.github.io/project/digit-recognition/live_hu8695550706911073452.webp 1200w"
src="https://juttu-s.github.io/saikiran_juttu.github.io/saikiran_juttu.github.io/project/digit-recognition/live_hu16096523079256018570.webp"
width="580"
height="760"
loading="lazy" data-zoomable />&lt;/div>
&lt;/div>&lt;/figure>
&lt;em>Figure: Live Webcam Digit Recognition&lt;/em>&lt;/p>
&lt;blockquote>
&lt;p>&lt;em>Sometimes predictions were inaccurate due to motion blur or poor lighting, but most digits were correctly classified in real-time.&lt;/em>&lt;/p>
&lt;/blockquote>
&lt;hr>
&lt;h2 id="key-takeaways">Key Takeaways&lt;/h2>
&lt;ul>
&lt;li>5x5 and 7x7 filters outperform 3x3 in MNIST CNNs&lt;/li>
&lt;li>Dropout around &lt;strong>0.1–0.3&lt;/strong> provided best generalization&lt;/li>
&lt;li>Smaller batch sizes (32) resulted in better accuracy&lt;/li>
&lt;li>Greek letters transfer learning worked well with small data (~27 samples)&lt;/li>
&lt;li>Real-time system demonstrates CNNs are efficient and deployable&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="tools">Tools&lt;/h2>
&lt;ul>
&lt;li>Python, PyTorch, OpenCV&lt;/li>
&lt;li>Jupyter Notebooks&lt;/li>
&lt;li>Matplotlib for visualization&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="resources">Resources&lt;/h2>
&lt;ul>
&lt;li>📄 &lt;a href="Project.pdf">Project Report PDF&lt;/a>&lt;/li>
&lt;li>🔗 &lt;a href="https://github.com/juttu-s/Pattern-Recognition-and-Computer-Vision/tree/main/Recognition%20using%20Deep%20Networks" target="_blank" rel="noopener">GitHub Repository&lt;/a>&lt;/li>
&lt;/ul>
&lt;hr>
&lt;p>This project deepened our understanding of CNN design, filter learning, transfer learning, and real-time deployment — showing the full lifecycle from dataset to deployment.&lt;/p></description></item><item><title>Real-Time 2D Object Recognition with Feature Matching</title><link>https://juttu-s.github.io/saikiran_juttu.github.io/project/object-recognition/</link><pubDate>Sat, 30 Mar 2024 00:00:00 +0000</pubDate><guid>https://juttu-s.github.io/saikiran_juttu.github.io/project/object-recognition/</guid><description>&lt;p>Recognise objects on a tabletop from a live webcam, using classical computer vision and nothing else — no learned features, no pretrained backbone. Camera overhead, dark objects on a white surface, everything computed per frame in C++.&lt;/p>
&lt;p>The constraint that made this interesting: most of the pipeline had to be written from scratch. Two of the first four stages were required to be; three ended up that way. The only OpenCV algorithm doing real work is connected-component labelling.&lt;/p>
&lt;p>
&lt;figure >
&lt;div class="flex justify-center ">
&lt;div class="w-100" >&lt;img alt="A watch and a pen segmented with oriented bounding boxes, axis-of-least-central-moment arrows, and live feature values overlaid" srcset="
/saikiran_juttu.github.io/project/object-recognition/features-annotated_hu1285270646848479672.webp 400w,
/saikiran_juttu.github.io/project/object-recognition/features-annotated_hu4246342232475753791.webp 760w,
/saikiran_juttu.github.io/project/object-recognition/features-annotated_hu5357358603646478662.webp 1200w"
src="https://juttu-s.github.io/saikiran_juttu.github.io/saikiran_juttu.github.io/project/object-recognition/features-annotated_hu1285270646848479672.webp"
width="640"
height="480"
loading="lazy" data-zoomable />&lt;/div>
&lt;/div>&lt;/figure>
&lt;em>Two objects at once. Blue boxes are the oriented bounding boxes, red arrows the axis of least central moment, cyan text the live feature values.&lt;/em>&lt;/p>
&lt;hr>
&lt;h2 id="pipeline">Pipeline&lt;/h2>
&lt;h3 id="thresholding-without-otsu">Thresholding, without Otsu&lt;/h3>
&lt;p>Rather than call a threshold function, the threshold is found by &lt;strong>2-means clustering on sampled pixel values&lt;/strong>. Sample the frame, converge two centroids — one settles on the dark object population, one on the light background — and put the threshold at their midpoint.&lt;/p>
&lt;p>The appeal is that it&amp;rsquo;s &lt;em>adaptive by construction&lt;/em>. As the lighting shifts, both centroids move and the threshold tracks them, without a hand-tuned constant anywhere. It&amp;rsquo;s a genuinely better fit for a live feed than a fixed cut, and it&amp;rsquo;s about fifteen lines of code.&lt;/p>
&lt;h3 id="morphological-cleanup">Morphological cleanup&lt;/h3>
&lt;p>The thresholded feed had holes in it — printed text and specular highlights on dark objects read as background. So: &lt;strong>dilation first to close the gaps, then erosion to remove the speckle&lt;/strong> the dilation amplified. Written by hand rather than called, and the ordering was driven by looking at the actual defect rather than reaching for a default.&lt;/p>
&lt;h3 id="segmentation">Segmentation&lt;/h3>
&lt;p>&lt;code>cv::connectedComponentsWithStats&lt;/code> labels the regions; components below a size threshold are dropped as noise. Survivors get distinct colours for display.&lt;/p>
&lt;h3 id="features-from-moments-up">Features, from moments up&lt;/h3>
&lt;p>Raw and central moments computed directly, then five descriptors per region:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Feature&lt;/th>
&lt;th>What it captures&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Centroid (x, y)&lt;/td>
&lt;td>Region position&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>θ&lt;/td>
&lt;td>Angle of the axis of least central moment — the object&amp;rsquo;s orientation&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Percent filled&lt;/td>
&lt;td>Region area ÷ oriented bounding box area&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Bounding box ratio&lt;/td>
&lt;td>Oriented box aspect ratio&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Stored to CSV alongside a label typed at capture time, which makes the training set inspectable — you can open it and see why the classifier does what it does.&lt;/p>
&lt;h2 id="classification">Classification&lt;/h2>
&lt;p>Two classifiers over the same features:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Nearest neighbour&lt;/strong> on cumulative scaled Euclidean distance — closest labelled example wins.&lt;/li>
&lt;li>&lt;strong>k-NN with k = 4&lt;/strong> — take the four nearest, majority vote.&lt;/li>
&lt;/ul>
&lt;p>The k-NN version is the more robust of the two, and the reason is visible in the failure mode of the first. Nearest neighbour commits to a single best match, so when two classes differ only marginally in feature space, one noisy frame is enough to flip the decision. Requiring agreement among four neighbours means a single outlier can&amp;rsquo;t carry the vote.&lt;/p>
&lt;hr>
&lt;h2 id="results">Results&lt;/h2>
&lt;p>&lt;strong>11 object classes&lt;/strong>, roughly 30 labelled samples captured at varying positions and orientations: watch, pen, mobile, spoon, bracelet, earbuds box, pendrive, statue, controller, star, clutch. Over 15 trials across five classes, classification accuracy ran &lt;strong>93.33–100%&lt;/strong>.&lt;/p>
&lt;p>The more informative result is how well the shape features separate the classes:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Feature&lt;/th>
&lt;th>Range across the 11 classes&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Percent filled&lt;/td>
&lt;td>0.34 (bracelet — a hollow loop) → 0.97 (phone — a filled rectangle)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Bounding box ratio&lt;/td>
&lt;td>1.04 (near-square box) → 8.88 (pen)&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Those two numbers do most of the discriminating, and they&amp;rsquo;re both scale- and rotation-invariant, which is the whole reason for computing orientation first and measuring the box &lt;em>after&lt;/em> aligning to it. Deliberately including several elongated objects — pen at 8.88, watch strap at 5.31, spoon at 3.99 — was what stress-tested it; anything can separate a pen from a phone, but separating a pen from a watch strap needs the percent-filled term to pull its weight.&lt;/p>
&lt;p>An extension pushed it to &lt;strong>multiple objects simultaneously&lt;/strong>, segmenting and classifying every region in the frame rather than assuming one object at a time.&lt;/p>
&lt;h2 id="honest-limitations">Honest limitations&lt;/h2>
&lt;p>&lt;strong>The feature vector includes absolute centroid position.&lt;/strong> Centroid x and y range from 165 to 484 px across the dataset, and feeding those into a scaled-Euclidean distance means &lt;em>where the object sits in frame&lt;/em> contributes to the class decision — in a system whose stated goal is translation invariance. The three shape descriptors are the invariant ones and are doing the real work; the centroid terms are a liability I&amp;rsquo;d drop.&lt;/p>
&lt;p>&lt;strong>Lighting drives everything.&lt;/strong> The 2-means threshold adapts, but it can only adapt to a bimodal scene. Introduce a shadow gradient across the white surface, or a mid-grey object, and the two-cluster assumption stops holding.&lt;/p>
&lt;p>&lt;strong>No timing was measured.&lt;/strong> The system runs interactively on a live feed, but there&amp;rsquo;s no frame-rate figure behind that, so I won&amp;rsquo;t claim one.&lt;/p>
&lt;h2 id="stack">Stack&lt;/h2>
&lt;p>C++, OpenCV 4, CMake. Thresholding, morphology and the entire moment/feature pipeline hand-written; &lt;code>connectedComponentsWithStats&lt;/code> for labelling. Training data self-collected via an in-app capture-and-label mode, with recording built in for demos.&lt;/p></description></item><item><title>Camera Calibration and Augmented Reality</title><link>https://juttu-s.github.io/saikiran_juttu.github.io/project/calibration/</link><pubDate>Mon, 18 Mar 2024 00:00:00 +0000</pubDate><guid>https://juttu-s.github.io/saikiran_juttu.github.io/project/calibration/</guid><description>&lt;p>Everything in marker-based AR rests on one number: how well you know the camera. This project works through that chain end to end in C++/OpenCV — detect a calibration target, solve for intrinsics, recover live pose, and project virtual geometry that stays locked to the target as the camera moves.&lt;/p>
&lt;p>
&lt;figure >
&lt;div class="flex justify-center ">
&lt;div class="w-100" >&lt;img alt="A green wireframe object rendered over a chessboard with rainbow corner polylines and RGB origin axes" srcset="
/saikiran_juttu.github.io/project/calibration/virtual-object_hu5133569573855807652.webp 400w,
/saikiran_juttu.github.io/project/calibration/virtual-object_hu6246078329211090082.webp 760w,
/saikiran_juttu.github.io/project/calibration/virtual-object_hu6792843897147638943.webp 1200w"
src="https://juttu-s.github.io/saikiran_juttu.github.io/saikiran_juttu.github.io/project/calibration/virtual-object_hu5133569573855807652.webp"
width="474"
height="411"
loading="lazy" data-zoomable />&lt;/div>
&lt;/div>&lt;/figure>
&lt;em>The virtual object holding its pose against the board.&lt;/em>&lt;/p>
&lt;hr>
&lt;h2 id="calibration">Calibration&lt;/h2>
&lt;p>A &lt;strong>9×6 chessboard giving 54 interior corners&lt;/strong>, detected on a live stream with sub-pixel refinement. Pressing &lt;code>s&lt;/code> banks the current frame&amp;rsquo;s corner locations along with their corresponding 3-D world points; &lt;code>cv::calibrateCamera&lt;/code> then solves for intrinsics and distortion. Fewer than five frames and it refuses — the parameters aren&amp;rsquo;t determined.&lt;/p>
&lt;p>Recovered intrinsics for the laptop camera, from an initial guess of nothing more than a principal point at the image centre:&lt;/p>
$$K = \begin{bmatrix} 611.41 &amp; 0 &amp; 311.84 \\ 0 &amp; 613.70 &amp; 236.82 \\ 0 &amp; 0 &amp; 1 \end{bmatrix}$$&lt;p>&lt;strong>Final reprojection error: 0.545 px.&lt;/strong> Intrinsics and distortion coefficients are written out as OpenCV YAML so later runs skip calibration entirely.&lt;/p>
&lt;h3 id="two-cameras-one-surprise">Two cameras, one surprise&lt;/h3>
&lt;p>Running the same procedure on an external USB webcam gave a notably better fit:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Camera&lt;/th>
&lt;th>fx, fy&lt;/th>
&lt;th>cx, cy&lt;/th>
&lt;th>Reprojection error&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Laptop integrated&lt;/td>
&lt;td>611.41, 613.70&lt;/td>
&lt;td>311.84, 236.82&lt;/td>
&lt;td>0.545 px&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>External USB webcam&lt;/td>
&lt;td>690.48, 693.37&lt;/td>
&lt;td>344.83, 239.23&lt;/td>
&lt;td>&lt;strong>0.150 px&lt;/strong>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The webcam calibrates about &lt;strong>3.6× better&lt;/strong> — and the interesting part is that it does so while having &lt;em>substantially larger&lt;/em> distortion coefficients (k1 = −0.446 against −0.215, k3 = −4.44 against −1.76). More distortion, better fit. Those aren&amp;rsquo;t contradictory: the reprojection error measures how well the &lt;em>model&lt;/em> explains the observations, not how mild the lens is. A strongly distorted lens whose distortion is well-described by the radial polynomial will beat a milder lens whose residual behaviour the model can&amp;rsquo;t represent. Cheap wide optics can calibrate beautifully; the question is whether the model matches the physics.&lt;/p>
&lt;h2 id="pose">Pose&lt;/h2>
&lt;p>With intrinsics fixed, &lt;code>solvePnP&lt;/code> returns rotation and translation per frame. The sanity check is nicer than eyeballing an overlay — translate the camera to the right and watch the numbers:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">tvec [7.378, 6.753, 40.531]
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">tvec [7.627, 6.719, 40.485]
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">tvec [7.759, 6.711, 40.504]
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">tvec [8.070, 6.713, 40.486]
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>X climbs monotonically, 7.378 → 8.070, while Y holds near 6.71 and Z near 40.49. That&amp;rsquo;s exactly right for pure lateral motion, and it&amp;rsquo;s a check on the physical meaning of the solution rather than on whether the render happens to look plausible.&lt;/p>
&lt;p>
&lt;figure >
&lt;div class="flex justify-center ">
&lt;div class="w-100" >&lt;img alt="Projected 3D axes anchored at the board origin with all 54 corners detected and linked" srcset="
/saikiran_juttu.github.io/project/calibration/projected-axes_hu14538787005341851264.webp 400w,
/saikiran_juttu.github.io/project/calibration/projected-axes_hu3207897690070648796.webp 760w,
/saikiran_juttu.github.io/project/calibration/projected-axes_hu17627915606788624727.webp 1200w"
src="https://juttu-s.github.io/saikiran_juttu.github.io/saikiran_juttu.github.io/project/calibration/projected-axes_hu14538787005341851264.webp"
width="633"
height="388"
loading="lazy" data-zoomable />&lt;/div>
&lt;/div>&lt;/figure>
&lt;em>Detected corners and the projected 3-D axes at the board origin.&lt;/em>&lt;/p>
&lt;p>&lt;code>cv::projectPoints&lt;/code> then puts 3-D world points back onto the image plane — the board corners, RGB axes at the origin, and a virtual object defined as &lt;strong>15 points forming an inverted C&lt;/strong>, connected by lines drawn between their projections.&lt;/p>
&lt;hr>
&lt;h2 id="extensions">Extensions&lt;/h2>
&lt;p>&lt;strong>Making the marker stop looking like a marker.&lt;/strong> The strongest result here: find the board&amp;rsquo;s four extreme corners, warp an arbitrary image into that quadrilateral, and render the virtual object on top of the warped result. The chessboard is still doing all the pose work underneath, but it&amp;rsquo;s no longer visible — which is the difference between a calibration demo and something you&amp;rsquo;d actually want in a scene.&lt;/p>
&lt;p>
&lt;figure >
&lt;div class="flex justify-center ">
&lt;div class="w-100" >&lt;img alt="An arbitrary image warped over the chessboard region with the virtual object drawn on top" srcset="
/saikiran_juttu.github.io/project/calibration/target-overlay_hu10769559096996454137.webp 400w,
/saikiran_juttu.github.io/project/calibration/target-overlay_hu16920568668342420622.webp 760w,
/saikiran_juttu.github.io/project/calibration/target-overlay_hu14363001692669884525.webp 1200w"
src="https://juttu-s.github.io/saikiran_juttu.github.io/saikiran_juttu.github.io/project/calibration/target-overlay_hu10769559096996454137.webp"
width="491"
height="395"
loading="lazy" data-zoomable />&lt;/div>
&lt;/div>&lt;/figure>
&lt;em>The board&amp;rsquo;s squares fully replaced, virtual geometry still tracking correctly.&lt;/em>&lt;/p>
&lt;p>&lt;strong>Two boards at once.&lt;/strong> A (9,6) and a (6,6) board handled in a single frame, each solved for pose independently and each rendered with its own coloured pyramid. It degrades gracefully — remove one board and the other keeps tracking.&lt;/p>
&lt;p>
&lt;figure >
&lt;div class="flex justify-center ">
&lt;div class="w-100" >&lt;img alt="Two checkerboards of different sizes tracked simultaneously, each with its own coloured wireframe pyramid" srcset="
/saikiran_juttu.github.io/project/calibration/multi-board_hu16617972295414141342.webp 400w,
/saikiran_juttu.github.io/project/calibration/multi-board_hu10299970603627648674.webp 760w,
/saikiran_juttu.github.io/project/calibration/multi-board_hu7114270305068571192.webp 1200w"
src="https://juttu-s.github.io/saikiran_juttu.github.io/saikiran_juttu.github.io/project/calibration/multi-board_hu16617972295414141342.webp"
width="656"
height="445"
loading="lazy" data-zoomable />&lt;/div>
&lt;/div>&lt;/figure>
&lt;em>Independent pose solutions for two different board geometries in one frame.&lt;/em>&lt;/p>
&lt;p>&lt;strong>Pre-recorded video mode.&lt;/strong> Pressing &lt;code>p&lt;/code> runs the whole pipeline over recorded footage instead of the live feed, inserting virtual objects into video after the fact.&lt;/p>
&lt;h2 id="toward-markerless">Toward markerless&lt;/h2>
&lt;p>&lt;code>cv::cornerHarris&lt;/code> was added to explore features that don&amp;rsquo;t require a known target, swept over threshold, block size, aperture size and the Harris free parameter &lt;em>k&lt;/em>. Raising the threshold from 150 to 200 visibly thins the detections, dropping the finer corners first.&lt;/p>
&lt;p>Detection is where this stopped, and it&amp;rsquo;s worth being precise about that: &lt;strong>the markerless pipeline was designed, not built.&lt;/strong> Going from Harris corners to a pose estimate needs frame-to-frame correspondence — SIFT or SURF descriptors matched across frames, then a transformation recovered from the matches, then projection using that pose. That&amp;rsquo;s the natural continuation and it isn&amp;rsquo;t implemented here.&lt;/p>
&lt;h2 id="a-note-on-the-target">A note on the target&lt;/h2>
&lt;p>The chessboard in these figures is being &lt;strong>displayed on a phone screen&lt;/strong> rather than printed. Convenient, and it never creases — but it&amp;rsquo;s a glossy emissive surface, which introduces specular highlights and probably accounts for some of the laptop camera&amp;rsquo;s worse reprojection error. A matte printed target on rigid backing is the better choice if the calibration itself is what matters.&lt;/p>
&lt;h2 id="stack">Stack&lt;/h2>
&lt;p>C++, OpenCV 4, CMake, on Windows and Ubuntu. &lt;code>calibrateCamera&lt;/code>, &lt;code>solvePnP&lt;/code>, &lt;code>projectPoints&lt;/code>, &lt;code>cornerHarris&lt;/code>; intrinsics persisted as OpenCV YAML. Two cameras: a laptop integrated camera and an external USB webcam.&lt;/p></description></item><item><title>Control Strategies for Quadrotor Position and Altitude</title><link>https://juttu-s.github.io/saikiran_juttu.github.io/project/quadrotor-control/</link><pubDate>Tue, 05 Dec 2023 00:00:00 +0000</pubDate><guid>https://juttu-s.github.io/saikiran_juttu.github.io/project/quadrotor-control/</guid><description>&lt;p>Three optimal-control formulations on the same quadrotor plant, compared on the same test, in MATLAB and Simulink. The interesting part turned out not to be which controller settles fastest — it was reading the control-effort axes afterwards and realising what the fastest one was spending to get there.&lt;/p>
&lt;p>Coursework for &lt;strong>ME5659: Control Systems Engineering&lt;/strong> at Northeastern.&lt;/p>
&lt;hr>
&lt;h2 id="plant">Plant&lt;/h2>
&lt;p>A 6-DOF rigid body from the Newton–Euler equations, following &lt;a href="https://www.diva-portal.org/smash/get/diva2:860649/FULLTEXT01.pdf" target="_blank" rel="noopener">Sabatino (2015)&lt;/a>:&lt;/p>
$$m(\omega_B \wedge v_B + \dot{v}_B) = f_B, \qquad I\dot{\omega}_B + \omega_B \wedge (I\omega_B) = m_B$$&lt;p>Four rotors give four control inputs — one collective thrust and three body torques:&lt;/p>
$$u = [f_t \quad \tau_x \quad \tau_y \quad \tau_z]^\mathsf{T}, \qquad \tau_x = bl(\Omega_3^2 - \Omega_1^2)$$&lt;p>Simulation constants: &lt;strong>m = 0.2 kg&lt;/strong>, &lt;strong>Ix = Iy = 0.1&lt;/strong>, &lt;strong>Iz = 0.15 kg·m²&lt;/strong>, sample time &lt;strong>0.01 s&lt;/strong>. Inertia is treated as diagonal, which assumes the body frame&amp;rsquo;s origin and axes coincide with the barycentre and principal axes.&lt;/p>
&lt;h3 id="linearisation">Linearisation&lt;/h3>
&lt;p>Three stacked simplifications get from the nonlinear model to something &lt;code>lqr()&lt;/code> will accept:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Euler rates identified with body rates&lt;/strong> — $[\dot\phi \; \dot\theta \; \dot\psi]^\mathsf{T} = [p \; q \; r]^\mathsf{T}$, valid for small angles.&lt;/li>
&lt;li>&lt;strong>Small oscillations&lt;/strong> — sines replaced by their arguments, cosines by unity.&lt;/li>
&lt;li>&lt;strong>Hover equilibrium&lt;/strong> — $\bar{u} = [mg \; 0 \; 0 \; 0]^\mathsf{T}$, the thrust that exactly cancels weight.&lt;/li>
&lt;/ol>
&lt;p>What comes out is fully decoupled: three double-integrator chains on attitude, three on position, with gravity coupling pitch into $\dot{u}$ and roll into $\dot{v}$. Twelve states, four inputs, and with $C = I_{12}$ (all states assumed measurable) both the controllability and observability matrices come back full rank 12.&lt;/p>
&lt;p>The honest caveat on all of this: validity is bounded by the small-angle assumption and never quantified. A wind-disturbance matrix $D \in \mathbb{R}^{12 \times 6}$ is derived from the rotor and body-force channels — and then never exercised, since every simulation runs with &lt;code>D = zeros&lt;/code>.&lt;/p>
&lt;hr>
&lt;h2 id="controllers">Controllers&lt;/h2>
&lt;p>&lt;strong>LQR&lt;/strong> — state feedback minimising $\int (x^\mathsf{T}Qx + u^\mathsf{T}Ru)\,dt$. Weights were left at $Q = I_{12}$, $R = I_4$; no weight tuning was performed, so all three controllers share the same underlying gain.&lt;/p>
&lt;p>&lt;strong>LQR-PI&lt;/strong> — PI action in the forward path, LQR state feedback around it, with an explicit injection point for unmodelled dynamics between the PI output and the plant. Integral action is what kills the steady-state error LQR leaves behind. Final hand-tune: &lt;code>Kp = [500, 2, 200, 1]&lt;/code>, &lt;code>Ki = [1, 1, 1, 1]&lt;/code>.&lt;/p>
&lt;p>&lt;strong>LQG&lt;/strong> — LQR plus a Kalman estimator, for the case where states are inferred rather than measured. Process noise covariance $10^{-3}$ on the six rate/velocity states, measurement noise $10^{-4}$.&lt;/p>
&lt;h2 id="test">Test&lt;/h2>
&lt;p>Not a step response — a &lt;strong>regulation-to-origin decay&lt;/strong>. The quadrotor starts displaced at (x, y, z) = &lt;strong>(5.5, 6, 7.5) m&lt;/strong> with zero attitude and zero rates, reference at the origin, and each controller drives it home.&lt;/p>
&lt;p>The study&amp;rsquo;s summary comparison:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Controller&lt;/th>
&lt;th>Max overshoot&lt;/th>
&lt;th>Settling time&lt;/th>
&lt;th>Parameter sensitivity&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>LQR&lt;/td>
&lt;td>12%&lt;/td>
&lt;td>2.5 s&lt;/td>
&lt;td>High&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>LQR-PI&lt;/td>
&lt;td>5%&lt;/td>
&lt;td>1.8 s&lt;/td>
&lt;td>Moderate&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>LQG&lt;/td>
&lt;td>3%&lt;/td>
&lt;td>1.2 s&lt;/td>
&lt;td>Low&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h3 id="re-deriving-it">Re-deriving it&lt;/h3>
&lt;p>Rebuilding the plant from the constants above and re-solving the LQR reproduces the original run exactly where it can be checked. Both the controllability and observability matrices come back rank 12; the open-loop eigenvalues are all zero — marginally stable, six pure integrator pairs, which is what you expect from a decoupled double-integrator model and worth noting because it means the plant is &lt;em>not&lt;/em> asymptotically stable before feedback. Closing the loop puts every eigenvalue at Re ≤ −1.0. And the initial control effort comes out at&lt;/p>
$$u(0) = [-7.5,\; -6.0,\; 5.5,\; 0]$$&lt;p>matching the report&amp;rsquo;s printed console output digit for digit — good evidence the model, weights and initial condition are transcribed correctly.&lt;/p>
&lt;p>What doesn&amp;rsquo;t reproduce is the table. With $Q = I_{12}$, $R = I_4$ and that initial condition, the position channels decay &lt;strong>monotonically with no overshoot at all&lt;/strong>, settling to 2% in about &lt;strong>4.4 s&lt;/strong> — not 12% overshoot in 2.5 s. The response plots in the report agree with the re-derivation rather than with its own summary table: all three position traces are smooth monotone decays reaching zero around 4–5 s.&lt;/p>
&lt;p>So I&amp;rsquo;d treat the overshoot and settling-time figures as unreliable, and the ordinal sensitivity ranking as qualitative judgement rather than measurement. What follows below is the part that &lt;em>is&lt;/em> visible in the data.&lt;/p>
&lt;p>
&lt;figure >
&lt;div class="flex justify-center ">
&lt;div class="w-100" >&lt;img alt="LQR-PI response: position decay, body moments, thrust and power perturbation" srcset="
/saikiran_juttu.github.io/project/quadrotor-control/lqr-pi-response_hu2407646783812282393.webp 400w,
/saikiran_juttu.github.io/project/quadrotor-control/lqr-pi-response_hu6685818478649532550.webp 760w,
/saikiran_juttu.github.io/project/quadrotor-control/lqr-pi-response_hu13583573080454341919.webp 1200w"
src="https://juttu-s.github.io/saikiran_juttu.github.io/saikiran_juttu.github.io/project/quadrotor-control/lqr-pi-response_hu2407646783812282393.webp"
width="760"
height="380"
loading="lazy" data-zoomable />&lt;/div>
&lt;/div>&lt;/figure>
&lt;em>LQR-PI. Positions decay cleanly — but look at the vertical axes in the right two columns.&lt;/em>&lt;/p>
&lt;p>
&lt;figure >
&lt;div class="flex justify-center ">
&lt;div class="w-100" >&lt;img alt="LQR response on the same test and the same layout" srcset="
/saikiran_juttu.github.io/project/quadrotor-control/lqr-response_hu8600440175981309337.webp 400w,
/saikiran_juttu.github.io/project/quadrotor-control/lqr-response_hu15163642574668778384.webp 760w,
/saikiran_juttu.github.io/project/quadrotor-control/lqr-response_hu10311598550682387049.webp 1200w"
src="https://juttu-s.github.io/saikiran_juttu.github.io/saikiran_juttu.github.io/project/quadrotor-control/lqr-response_hu8600440175981309337.webp"
width="760"
height="380"
loading="lazy" data-zoomable />&lt;/div>
&lt;/div>&lt;/figure>
&lt;em>LQR, same test, same panel layout. Same position traces, wildly different actuator numbers.&lt;/em>&lt;/p>
&lt;hr>
&lt;h2 id="what-the-settling-time-cost">What the settling time cost&lt;/h2>
&lt;p>Put those two figures side by side and the position columns are nearly indistinguishable — both controllers bring x, y and z to zero inside about five seconds with no visible overshoot. The difference is entirely in the actuator channels:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>&lt;/th>
&lt;th>LQR&lt;/th>
&lt;th>LQR-PI&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Thrust perturbation, peak&lt;/td>
&lt;td>≈ −6&lt;/td>
&lt;td>≈ −3500&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Pitch moment, peak&lt;/td>
&lt;td>≈ +4&lt;/td>
&lt;td>≈ +1000&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Power perturbation, peak&lt;/td>
&lt;td>≈ 1.5&lt;/td>
&lt;td>≈ 10&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>LQR-PI pays roughly &lt;strong>500×&lt;/strong> the peak thrust perturbation for a position response that is visually indistinguishable. That is a direct consequence of &lt;code>Kp = [500, 2, 200, 1]&lt;/code> — the aggressive entries sit on exactly the thrust and pitch-torque channels where the blow-up appears. And unlike the settling times, this one is unambiguous: it&amp;rsquo;s printed on the axes of the report&amp;rsquo;s own figures.&lt;/p>
&lt;p>On a real airframe those commands are far outside what any rotor can deliver, so the controller would saturate immediately and whatever settling-time advantage it has on paper would evaporate.&lt;/p>
&lt;p>This is the part of the exercise that actually taught me something: a settling-time comparison with unbounded actuators isn&amp;rsquo;t a comparison of controllers, it&amp;rsquo;s a comparison of how hard each one is willing to push. Judge on overshoot and settling time alone and LQR-PI looks like a free improvement — plot the control effort and it obviously isn&amp;rsquo;t. Actuator limits belong &lt;em>in&lt;/em> the problem formulation, which is the argument for constrained optimal control rather than a discovery you make afterwards in the plots.&lt;/p>
&lt;p>Two further limits worth stating plainly. &lt;strong>Yaw was never exercised&lt;/strong> — the yaw moment sits on a $\times 10^{-15}$ axis in both runs, i.e. numerically zero, because initial yaw is zero and the linearised yaw channel is fully decoupled. And the &lt;strong>LQG run had noise switched off&lt;/strong> (&lt;code>NL = 0&lt;/code>), so that row shows the estimator in the loop but does not demonstrate the noise rejection that is the entire reason to prefer LQG. This is a simulation-only study; there was no hardware.&lt;/p>
&lt;hr>
&lt;h2 id="the-test-couldnt-show-what-lqr-pi-is-for">The test couldn&amp;rsquo;t show what LQR-PI is for&lt;/h2>
&lt;p>Rebuilding the three loops to check the numbers turned up something the study missed
entirely, and it&amp;rsquo;s the most useful thing I took away from the project.&lt;/p>
&lt;p>On this test, &lt;strong>plain LQR already drives the state to zero.&lt;/strong> Final position error comes out
around 10⁻⁴ m, which is numerical integration error, not a control deficiency. There is no
steady-state error, no persistent disturbance, no model mismatch. So integral action has
nothing to remove — and adding it can only contribute phase lag. Re-running with proper
integral action confirms exactly that: settling gets &lt;em>worse&lt;/em> (5.9 s against LQR&amp;rsquo;s 4.4 s) and
overshoot appears where there was none.&lt;/p>
&lt;p>Which means the standard justification for LQR-PI — that it eliminates steady-state error — is
true, but &lt;strong>the test cannot demonstrate it.&lt;/strong> A regulation-to-origin decay on an undisturbed
linear model is precisely the case where PI has no work to do.&lt;/p>
&lt;p>The fix is to disturb it. Adding a constant wind force of 0.3 N and 0.2 N — using the
disturbance matrix the report derives and then never exercises — separates them immediately:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Controller&lt;/th>
&lt;th>Steady-state position error under constant wind&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>LQR&lt;/td>
&lt;td>&lt;strong>1.24 m&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>LQR-PI&lt;/td>
&lt;td>&lt;strong>0.00 m&lt;/strong>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>That&amp;rsquo;s the comparison the study should have run. It costs one extra term in the simulation, it
uses a matrix already derived on page 13, and it turns &amp;ldquo;LQR-PI settles 0.7 s faster&amp;rdquo; — a claim
that doesn&amp;rsquo;t survive re-derivation — into a difference that is unambiguous and large.&lt;/p>
&lt;h2 id="what-id-do-differently">What I&amp;rsquo;d do differently&lt;/h2>
&lt;p>Drop the linearisation. The plant is meaningfully nonlinear away from hover, and the natural next step is &lt;strong>nonlinear model-predictive control&lt;/strong> on the full model — which also gets you actuator constraints in the formulation rather than discovered afterwards in the plots, and lets you track a planned trajectory through a set of waypoints instead of regulating to a single point. Tuning $Q$ and $R$ rather than leaving them at identity would be the cheaper first improvement.&lt;/p>
&lt;h2 id="stack">Stack&lt;/h2>
&lt;p>MATLAB (&lt;code>ss&lt;/code>, &lt;code>ctrb&lt;/code>, &lt;code>obsv&lt;/code>, &lt;code>lqr&lt;/code>), Simulink, three models for the three controllers.&lt;/p></description></item></channel></rss>