├── settings.gradle
├── app
├── src
│ └── main
│ │ ├── res
│ │ ├── mipmap-hdpi
│ │ │ ├── ic_launcher.png
│ │ │ └── ic_launcher_round.png
│ │ ├── mipmap-mdpi
│ │ │ ├── ic_launcher.png
│ │ │ └── ic_launcher_round.png
│ │ ├── mipmap-xhdpi
│ │ │ ├── ic_launcher.png
│ │ │ └── ic_launcher_round.png
│ │ ├── mipmap-xxhdpi
│ │ │ ├── ic_launcher.png
│ │ │ └── ic_launcher_round.png
│ │ ├── mipmap-xxxhdpi
│ │ │ ├── ic_launcher.png
│ │ │ └── ic_launcher_round.png
│ │ ├── raw
│ │ │ ├── pixel1_phaseconfig.json
│ │ │ ├── pixel2_phaseconfig.json
│ │ │ ├── pixel3_240fps_phaseconfig.json
│ │ │ ├── pixel3_phaseconfig.json
│ │ │ └── pixel3_60fps_phaseconfig.json
│ │ ├── values
│ │ │ ├── colors.xml
│ │ │ ├── dimens.xml
│ │ │ ├── styles.xml
│ │ │ └── strings.xml
│ │ ├── mipmap-anydpi-v26
│ │ │ ├── ic_launcher.xml
│ │ │ └── ic_launcher_round.xml
│ │ ├── drawable-v24
│ │ │ └── ic_launcher_foreground.xml
│ │ ├── layout
│ │ │ └── activity_main.xml
│ │ └── drawable
│ │ │ └── ic_launcher_background.xml
│ │ ├── java
│ │ └── com
│ │ │ └── googleresearch
│ │ │ └── capturesync
│ │ │ ├── softwaresync
│ │ │ ├── Ticker.java
│ │ │ ├── SystemTicker.java
│ │ │ ├── RpcCallback.java
│ │ │ ├── TimeUtils.java
│ │ │ ├── TimeDomainConverter.java
│ │ │ ├── SntpOffsetResponse.java
│ │ │ ├── SyncConstants.java
│ │ │ ├── ClientInfo.java
│ │ │ ├── NetworkHelpers.java
│ │ │ ├── SntpListener.java
│ │ │ ├── phasealign
│ │ │ │ ├── PhaseConfig.java
│ │ │ │ ├── PhaseResponse.java
│ │ │ │ └── PhaseAligner.java
│ │ │ ├── SoftwareSyncClient.java
│ │ │ ├── SoftwareSyncBase.java
│ │ │ ├── SimpleNetworkTimeProtocol.java
│ │ │ └── SoftwareSyncLeader.java
│ │ │ ├── Frame.java
│ │ │ ├── Constants.java
│ │ │ ├── AutoFitSurfaceView.java
│ │ │ ├── CaptureRequestFactory.java
│ │ │ ├── PhaseAlignController.java
│ │ │ ├── CameraController.java
│ │ │ ├── SoftwareSyncController.java
│ │ │ ├── ResultProcessor.java
│ │ │ └── ImageMetadataSynchronizer.java
│ │ └── AndroidManifest.xml
├── proguard-rules.pro
├── build.gradle
└── app.iml
├── CaptureSync.iml
├── CONTRIBUTING.md
├── scripts
└── yuv2rgb.py
├── LICENSE
└── README.md
/settings.gradle:
--------------------------------------------------------------------------------
1 | include ':app'
2 | rootProject.name='CaptureSync'
3 |
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-hdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/google-research/libsoftwaresync/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-mdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/google-research/libsoftwaresync/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/google-research/libsoftwaresync/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/google-research/libsoftwaresync/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/google-research/libsoftwaresync/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-hdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/google-research/libsoftwaresync/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-mdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/google-research/libsoftwaresync/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/google-research/libsoftwaresync/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/google-research/libsoftwaresync/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/google-research/libsoftwaresync/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/app/src/main/res/raw/pixel1_phaseconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "periodNs": 33325000,
3 | "goalPhaseNs": 15000000,
4 | "alignThresholdNs": 200000,
5 | "overheadNs": 200000,
6 | "minExposureNs": 33170000
7 | }
8 |
--------------------------------------------------------------------------------
/app/src/main/res/raw/pixel2_phaseconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "periodNs": 33319419,
3 | "goalPhaseNs": 15000000,
4 | "alignThresholdNs": 200000,
5 | "overheadNs": 200000,
6 | "minExposureNs": 33170000
7 | }
8 |
--------------------------------------------------------------------------------
/app/src/main/res/raw/pixel3_240fps_phaseconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "periodNs": 4164011,
3 | "goalPhaseNs": 2000000,
4 | "alignThresholdNs": 100000,
5 | "overheadNs": 196499,
6 | "minExposureNs": 4164011
7 | }
8 |
--------------------------------------------------------------------------------
/app/src/main/res/raw/pixel3_phaseconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "periodNs": 33320000,
3 | "goalPhaseNs": 15000000,
4 | "alignThresholdNs": 100000,
5 | "overheadNs": 200000,
6 | "minExposureNs": 33370000
7 | }
8 |
--------------------------------------------------------------------------------
/app/src/main/res/raw/pixel3_60fps_phaseconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "periodNs": 16630105,
3 | "goalPhaseNs": 7000000,
4 | "alignThresholdNs": 100000,
5 | "overheadNs": 196499,
6 | "minExposureNs": 16530105
7 | }
8 |
--------------------------------------------------------------------------------
/app/src/main/res/values/colors.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | #008577
4 | #00574B
5 | #D81B60
6 |
7 |
--------------------------------------------------------------------------------
/app/src/main/res/values/dimens.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | 16dp
5 | 16dp
6 |
7 |
8 |
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/app/src/main/res/values/styles.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
7 |
14 |
15 |
16 |
19 |
20 |
21 |
--------------------------------------------------------------------------------
/app/proguard-rules.pro:
--------------------------------------------------------------------------------
1 | # Add project specific ProGuard rules here.
2 | # You can control the set of applied configuration files using the
3 | # proguardFiles setting in build.gradle.
4 | #
5 | # For more details, see
6 | # http://developer.android.com/guide/developing/tools/proguard.html
7 |
8 | # If your project uses WebView with JS, uncomment the following
9 | # and specify the fully qualified class name to the JavaScript interface
10 | # class:
11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview {
12 | # public *;
13 | #}
14 |
15 | # Uncomment this to preserve the line number information for
16 | # debugging stack traces.
17 | #-keepattributes SourceFile,LineNumberTable
18 |
19 | # If you keep the line number information, uncomment this to
20 | # hide the original source file name.
21 | #-renamesourcefileattribute SourceFile
22 |
--------------------------------------------------------------------------------
/CaptureSync.iml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
--------------------------------------------------------------------------------
/app/src/main/java/com/googleresearch/capturesync/softwaresync/Ticker.java:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright 2019 The Google Research Authors.
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package com.googleresearch.capturesync.softwaresync;
18 |
19 | /** Interface for getting the time in nanoseconds, abstracting out accessing system time. */
20 | public interface Ticker {
21 | /* Returns the time in nanoseconds. */
22 | long read();
23 | }
24 |
--------------------------------------------------------------------------------
/app/build.gradle:
--------------------------------------------------------------------------------
1 | apply plugin: 'com.android.application'
2 |
3 | android {
4 | compileSdkVersion 29
5 | buildToolsVersion "29.0.2"
6 | defaultConfig {
7 | applicationId "com.googleresearch.capturesync"
8 | minSdkVersion 28
9 | targetSdkVersion 29
10 | versionCode 1
11 | versionName "1.0"
12 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
13 | }
14 | buildTypes {
15 | release {
16 | minifyEnabled false
17 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
18 | }
19 | }
20 | compileOptions {
21 | sourceCompatibility = 1.8
22 | targetCompatibility = 1.8
23 | }
24 | }
25 |
26 | dependencies {
27 | implementation fileTree(dir: 'libs', include: ['*.jar'])
28 | implementation 'androidx.appcompat:appcompat:1.0.2'
29 | testImplementation 'junit:junit:4.12'
30 | androidTestImplementation 'androidx.test.ext:junit:1.1.0'
31 | androidTestImplementation 'androidx.test.espresso:espresso-core:3.1.1'
32 | }
33 |
--------------------------------------------------------------------------------
/app/src/main/java/com/googleresearch/capturesync/softwaresync/SystemTicker.java:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright 2019 The Google Research Authors.
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package com.googleresearch.capturesync.softwaresync;
18 |
19 | import android.os.SystemClock;
20 |
21 | /** Simple implementation of Ticker interface using the SystemClock elapsed realtime clock. */
22 | public class SystemTicker implements Ticker {
23 | @Override
24 | public long read() {
25 | return SystemClock.elapsedRealtimeNanos();
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/app/src/main/java/com/googleresearch/capturesync/softwaresync/RpcCallback.java:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright 2019 The Google Research Authors.
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package com.googleresearch.capturesync.softwaresync;
18 |
19 | /** Interface for RPC callbacks, the base methods used for communicating between devices. */
20 | public interface RpcCallback {
21 |
22 | /**
23 | * The callback method called when an RPC is received.
24 | *
25 | * @param payload Contains the payload sent by the RPC.
26 | */
27 | void call(String payload);
28 | }
29 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # How to Contribute
2 |
3 | We'd love to accept your patches and contributions to this project. There are
4 | just a few small guidelines you need to follow.
5 |
6 | ## Contributor License Agreement
7 |
8 | Contributions to this project must be accompanied by a Contributor License
9 | Agreement. You (or your employer) retain the copyright to your contribution;
10 | this simply gives us permission to use and redistribute your contributions as
11 | part of the project. Head over to to see
12 | your current agreements on file or to sign a new one.
13 |
14 | You generally only need to submit a CLA once, so if you've already submitted one
15 | (even if it was for a different project), you probably don't need to do it
16 | again.
17 |
18 | ## Code reviews
19 |
20 | All submissions, including submissions by project members, require review. We
21 | use GitHub pull requests for this purpose. Consult
22 | [GitHub Help](https://help.github.com/articles/about-pull-requests/) for more
23 | information on using pull requests.
24 |
25 | ## Community Guidelines
26 |
27 | This project follows [Google's Open Source Community
28 | Guidelines](https://opensource.google/conduct/).
29 |
--------------------------------------------------------------------------------
/app/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
17 |
18 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
--------------------------------------------------------------------------------
/app/src/main/java/com/googleresearch/capturesync/softwaresync/TimeUtils.java:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright 2019 The Google Research Authors.
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package com.googleresearch.capturesync.softwaresync;
18 |
19 | /** Helper conversions between time scales. */
20 | public final class TimeUtils {
21 |
22 | public static double nanosToMillis(double nanos) {
23 | return nanos / 1_000_000L;
24 | }
25 |
26 | public static long nanosToSeconds(long nanos) {
27 | return nanos / 1_000_000_000L;
28 | }
29 |
30 | public static double nanosToSeconds(double nanos) {
31 | return nanos / 1_000_000_000L;
32 | }
33 |
34 | public static long millisToNanos(long millis) {
35 | return millis * 1_000_000L;
36 | }
37 |
38 | public static long secondsToNanos(int seconds) {
39 | return seconds * 1_000_000_000L;
40 | }
41 |
42 | private TimeUtils() {}
43 | }
44 |
--------------------------------------------------------------------------------
/app/src/main/java/com/googleresearch/capturesync/Frame.java:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright 2019 The Google Research Authors.
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package com.googleresearch.capturesync;
18 |
19 | import android.hardware.camera2.CaptureResult;
20 | import android.media.Image;
21 | import com.googleresearch.capturesync.ImageMetadataSynchronizer.Output;
22 | import java.io.Closeable;
23 |
24 | /** A holder for a CaptureResult with multiple outputs. */
25 | public class Frame implements Closeable {
26 | public final CaptureResult result;
27 | public final Output output;
28 | private boolean closed = false;
29 |
30 | public Frame(CaptureResult result, Output output) {
31 | this.result = result;
32 | this.output = output;
33 | }
34 |
35 | @Override
36 | public void close() {
37 | if (closed) {
38 | throw new IllegalStateException("This Frame is already closed");
39 | }
40 | for (Image image : output.images) {
41 | image.close();
42 | }
43 | output.close();
44 | closed = true;
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/app/src/main/java/com/googleresearch/capturesync/softwaresync/TimeDomainConverter.java:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright 2019 The Google Research Authors.
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package com.googleresearch.capturesync.softwaresync;
18 |
19 | /**
20 | * Interface used to manage time domain conversion, implemented by {@link SoftwareSyncBase}. This
21 | * allows {@link ResultProcessor} to convert sensor timestamps to the synchronized time domain
22 | * without needing full access to the softwaresync object.
23 | */
24 | public interface TimeDomainConverter {
25 |
26 | /**
27 | * Calculates the leader time associated with the given local time in nanoseconds. The local time
28 | * must be in the SystemClock.elapsedRealTimeNanos() localClock domain, nanosecond units. This
29 | * includes timestamps such as the sensor timestamp from the camera. leader_time =
30 | * local_elapsed_time_ns + leader_from_local_ns.
31 | *
32 | * @param localTimeNs given local time (local clock SystemClock.elapsedRealtimeNanos() domain).
33 | * @return leader synchronized time in nanoseconds.
34 | */
35 | long leaderTimeForLocalTimeNs(long localTimeNs);
36 |
37 | }
38 |
--------------------------------------------------------------------------------
/app/src/main/res/values/strings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CaptureSync
6 |
7 | Capture Still
8 |
9 | Align Phases
10 |
11 | Align 2A
12 |
13 |
14 | SoftwareSync Demo App
15 |
16 | Phase
17 |
18 | Exposure:
19 |
20 | Sensitivity:
21 |
22 |
23 | You must grant camera, read, and write permissions
24 |
25 | Failed to open Camera2 service
26 |
27 | Failed to capture viewfinder frame
28 |
29 | No image outputs chosen
30 |
31 | Buffers
32 |
33 |
34 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable-v24/ic_launcher_foreground.xml:
--------------------------------------------------------------------------------
1 |
7 |
12 |
13 |
19 |
22 |
25 |
26 |
27 |
28 |
34 |
35 |
--------------------------------------------------------------------------------
/app/src/main/java/com/googleresearch/capturesync/Constants.java:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright 2019 The Google Research Authors.
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package com.googleresearch.capturesync;
18 |
19 | import android.hardware.camera2.CameraCharacteristics;
20 | import com.googleresearch.capturesync.softwaresync.TimeUtils;
21 |
22 | /** Constants including what type of images to save and the save directory. */
23 | public final class Constants {
24 | public static final int DEFAULT_CAMERA_FACING = CameraCharacteristics.LENS_FACING_BACK;
25 |
26 | /**
27 | * Delay from capture button press to capture, giving network time to send messages to clients.
28 | */
29 | public static final long FUTURE_TRIGGER_DELAY_NS = TimeUtils.millisToNanos(500);
30 |
31 | /* Set at least one of {SAVE_YUV, SAVE_RAW} to true to save any data. */
32 | public static final boolean SAVE_YUV = true;
33 |
34 | // TODO: Implement saving ImageFormat.RAW10 to DNG.
35 | // DngCreator works with ImageFormat.RAW_SENSOR but it is slow and power-hungry.
36 | public static final boolean SAVE_RAW = false;
37 |
38 | // TODO(samansari): Turn SAVE_JPG_FROM_YUV into a checkbox instead.
39 | /* Set true to save a JPG to the gallery for preview. This is slow but gives you a "postview". */
40 | public static final boolean SAVE_JPG_FROM_YUV = true;
41 | public static final int JPG_QUALITY = 95;
42 |
43 | public static final boolean USE_FULL_SCREEN_IMMERSIVE = false;
44 |
45 | private Constants() {}
46 | }
47 |
--------------------------------------------------------------------------------
/app/src/main/java/com/googleresearch/capturesync/softwaresync/SntpOffsetResponse.java:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright 2019 The Google Research Authors.
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package com.googleresearch.capturesync.softwaresync;
18 |
19 | /** AutoValue class for SNTP offsetNs, synchronization accuracy and status. */
20 | public final class SntpOffsetResponse {
21 | private final long offsetNs;
22 | private final long syncAccuracyNs;
23 | private final boolean status;
24 |
25 | static SntpOffsetResponse create(long offset, long syncAccuracy, boolean status) {
26 | return new SntpOffsetResponse(offset, syncAccuracy, status);
27 | }
28 |
29 | private SntpOffsetResponse(long offsetNs, long syncAccuracyNs, boolean status) {
30 | this.offsetNs = offsetNs;
31 | this.syncAccuracyNs = syncAccuracyNs;
32 | this.status = status;
33 | }
34 |
35 | /**
36 | * The time delta (leader - client) in nanoseconds of the AP SystemClock domain.
37 | *
38 | *
The client can take their local_time to get leader_time via: local_time (leader - client) =
39 | * leader_time.
40 | */
41 | public long offsetNs() {
42 | return offsetNs;
43 | }
44 |
45 | /**
46 | * The worst case error in the clock domains between leader and client for this response, in
47 | * nanoseconds of the AP SystemClock domain.
48 | */
49 | public long syncAccuracyNs() {
50 | return syncAccuracyNs;
51 | }
52 |
53 | /** The success status of this response. */
54 | public boolean status() {
55 | return status;
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/scripts/yuv2rgb.py:
--------------------------------------------------------------------------------
1 | # coding=utf-8
2 | # Copyright 2019 The Google Research Authors.
3 | #
4 | # Licensed under the Apache License, Version 2.0 (the "License");
5 | # you may not use this file except in compliance with the License.
6 | # You may obtain a copy of the License at
7 | #
8 | # http://www.apache.org/licenses/LICENSE-2.0
9 | #
10 | # Unless required by applicable law or agreed to in writing, software
11 | # distributed under the License is distributed on an "AS IS" BASIS,
12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | # See the License for the specific language governing permissions and
14 | # limitations under the License.
15 |
16 | """Process raw NV21 images to jpg or png images using ffmpeg.
17 |
18 | This script requires ffmpeg (https://www.ffmpeg.org/download.html).
19 |
20 | Run with:
21 | python3 yuv2rgb.py img_.nv21 nv21_metadata_.txt
22 | out..
23 | """
24 |
25 | import argparse
26 | import subprocess
27 |
28 |
29 | def parse_meta(path):
30 | with open(path, 'r') as f:
31 | lines = [l.strip() for l in f.readlines()]
32 | img_width = lines[0].split(' ')[1]
33 | img_height = lines[1].split(' ')[1]
34 | img_pixel_format = lines[2].split(' ')[1]
35 | return img_width, img_height, img_pixel_format
36 |
37 |
38 | if __name__ == '__main__':
39 | parser = argparse.ArgumentParser()
40 | parser.add_argument(
41 | '-y', '--overwrite', help='Overwrite output.', action='store_true')
42 | parser.add_argument('input')
43 | parser.add_argument('meta')
44 | parser.add_argument('output', help='output filename (ending in .jpg or .png)')
45 | args = parser.parse_args()
46 |
47 | if not args.output.endswith('.jpg') and not args.output.endswith('.png'):
48 | raise ValueError('output must end in jpg or png.')
49 |
50 | width, height, pixel_format = parse_meta(args.meta)
51 | pixel_format = pixel_format.lower()
52 |
53 | overwrite_flag = '-y' if args.overwrite else '-n'
54 |
55 | cmd = [
56 | 'ffmpeg', overwrite_flag, '-f', 'image2', '-vcodec', 'rawvideo',
57 | '-pix_fmt', pixel_format, '-s', f'{width}x{height}', '-i', args.input,
58 | args.output
59 | ]
60 |
61 | subprocess.call(cmd)
62 |
--------------------------------------------------------------------------------
/app/src/main/java/com/googleresearch/capturesync/softwaresync/SyncConstants.java:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright 2019 The Google Research Authors.
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package com.googleresearch.capturesync.softwaresync;
18 |
19 | /** Ports and other constants used by SoftwareSync. */
20 | public class SyncConstants {
21 | public static final int SOCKET_WAIT_TIME_MS = 500;
22 |
23 | /** Heartbeat period between clients and leader. */
24 | public static final long HEARTBEAT_PERIOD_NS = TimeUtils.secondsToNanos(1);
25 |
26 | /**
27 | * Time until a lack of a heartbeat acknowledge from leader means a lost connection. Similarly the
28 | * time until a lack of a heartbeat from a client means the client is stale.
29 | */
30 | public static final long STALE_TIME_NS = 2 * HEARTBEAT_PERIOD_NS;
31 |
32 | /** Time until a given offsetNs by the leader is considered stale. */
33 | public static final long STALE_OFFSET_TIME_NS = TimeUtils.secondsToNanos(60 * 60);
34 |
35 | /** RPC. */
36 | public static final int RPC_PORT = 8244;
37 | public static final int RPC_BUFFER_SIZE = 1024;
38 |
39 | /** RPC Method ids.
40 | * [0 - 9,999] Reserved for SoftwareSync.
41 | * - [0 - 99] Synchronization-related.
42 | * - [100 - 199] Messages.
43 | * [10,000+ ] Available to user applications.
44 | */
45 | public static final int METHOD_HEARTBEAT = 1;
46 | public static final int METHOD_HEARTBEAT_ACK = 2;
47 | public static final int METHOD_OFFSET_UPDATE = 3;
48 |
49 | /* Define user RPC method ids using values greater or equal to this. */
50 | public static final int START_NON_SOFTWARESYNC_METHOD_IDS = 1_000;
51 |
52 | public static final int METHOD_MSG_ADDED_CLIENT = 1_101;
53 | public static final int METHOD_MSG_REMOVED_CLIENT = 1_102;
54 | public static final int METHOD_MSG_WAITING_FOR_LEADER = 1_103;
55 | public static final int METHOD_MSG_SYNCING = 1_104;
56 | public static final int METHOD_MSG_OFFSET_UPDATED = 1_105;
57 |
58 | /** Clock Sync - Simple Network Time Protocol (SNTP). */
59 | public static final int SNTP_PORT = 9428;
60 | public static final int SNTP_BUFFER_SIZE = 512;
61 | public static final int NUM_SNTP_CYCLES = 300;
62 | public static final long MIN_ROUND_TRIP_LATENCY_NS = TimeUtils.millisToNanos(1);
63 |
64 | private SyncConstants() {}
65 | }
66 |
--------------------------------------------------------------------------------
/app/src/main/java/com/googleresearch/capturesync/softwaresync/ClientInfo.java:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright 2019 The Google Research Authors.
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package com.googleresearch.capturesync.softwaresync;
18 |
19 | import java.net.InetAddress;
20 |
21 | /**
22 | * Utility immutable class for providing accessors for client name, address, ip local address
23 | * ending, current best accuracy, and last known heartbeat.
24 | */
25 | public final class ClientInfo {
26 | private final String name;
27 | private final InetAddress address;
28 | private final long offsetNs;
29 | private final long syncAccuracyNs;
30 | private final long lastHeartbeatNs;
31 |
32 | static ClientInfo create(
33 | String name, InetAddress address, long offset, long syncAccuracy, long lastHeartbeat) {
34 | return new ClientInfo(name, address, offset, syncAccuracy, lastHeartbeat);
35 | }
36 |
37 | static ClientInfo create(String name, InetAddress address) {
38 | return new ClientInfo(
39 | name, address, /*offsetNs=*/ 0, /*syncAccuracyNs=*/ 0, /*lastHeartbeatNs=*/ 0);
40 | }
41 |
42 | private ClientInfo(
43 | String name, InetAddress address, long offsetNs, long syncAccuracyNs, long lastHeartbeatNs) {
44 | this.name = name;
45 | this.address = address;
46 | this.offsetNs = offsetNs;
47 | this.syncAccuracyNs = syncAccuracyNs;
48 | this.lastHeartbeatNs = lastHeartbeatNs;
49 | }
50 |
51 | public String name() {
52 | return name;
53 | }
54 |
55 | public InetAddress address() {
56 | return address;
57 | }
58 |
59 | /**
60 | * The time delta (leader - client) in nanoseconds of the AP SystemClock domain. The client can
61 | * take their local_time to get leader_time via: local_time (leader - client) = leader_time.
62 | */
63 | public long offset() {
64 | return offsetNs;
65 | }
66 |
67 | /**
68 | * The worst case error in the clock domains between leader and client for this response, in
69 | * nanoseconds of the AP SystemClock domain.
70 | */
71 | public long syncAccuracy() {
72 | return syncAccuracyNs;
73 | }
74 |
75 | /* The last time a client heartbeat was detected. */
76 | public long lastHeartbeat() {
77 | return lastHeartbeatNs;
78 | }
79 |
80 | @Override
81 | public String toString() {
82 | return String.format("%s[%.2f ms]", name(), TimeUtils.nanosToMillis((double) syncAccuracy()));
83 | }
84 | }
85 |
--------------------------------------------------------------------------------
/app/src/main/java/com/googleresearch/capturesync/AutoFitSurfaceView.java:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright 2019 The Google Research Authors.
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package com.googleresearch.capturesync;
18 |
19 | import android.content.Context;
20 | import android.util.AttributeSet;
21 | import android.util.Log;
22 | import android.view.SurfaceView;
23 |
24 | /** A {@link SurfaceView} that can be adjusted to a specified aspect ratio. */
25 | public class AutoFitSurfaceView extends SurfaceView {
26 |
27 | private static final String TAG = "AutoFitSurfaceView";
28 |
29 | private int ratioWidth = 0;
30 | private int ratioHeight = 0;
31 |
32 | public AutoFitSurfaceView(Context context) {
33 | this(context, null);
34 | }
35 |
36 | public AutoFitSurfaceView(Context context, AttributeSet attrs) {
37 | this(context, attrs, 0);
38 | }
39 |
40 | public AutoFitSurfaceView(Context context, AttributeSet attrs, int defStyle) {
41 | super(context, attrs, defStyle);
42 | }
43 |
44 | /**
45 | * Sets the aspect ratio for this view. The size of the view will be measured based on the ratio
46 | * calculated from the parameters. Note that the actual sizes of parameters don't matter, that is,
47 | * calling setAspectRatio(2, 3) and setAspectRatio(4, 6) make the same result.
48 | *
49 | * @param width Relative horizontal size
50 | * @param height Relative vertical size
51 | */
52 | public void setAspectRatio(int width, int height) {
53 | if (width < 0 || height < 0) {
54 | throw new IllegalArgumentException("Size cannot be negative.");
55 | }
56 | ratioWidth = width;
57 | ratioHeight = height;
58 | requestLayout();
59 | }
60 |
61 | @Override
62 | protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
63 | super.onMeasure(widthMeasureSpec, heightMeasureSpec);
64 | int width = MeasureSpec.getSize(widthMeasureSpec);
65 | int height = MeasureSpec.getSize(heightMeasureSpec);
66 | if (0 == ratioWidth || 0 == ratioHeight) {
67 | Log.d(
68 | TAG,
69 | String.format(
70 | "aspect ratio is 0 x 0 (uninitialized), setting measured" + " dimension to: %d x %d",
71 | width, height));
72 | setMeasuredDimension(width, height);
73 | } else {
74 | if (width < height * ratioWidth / ratioHeight) {
75 | Log.d(TAG, String.format("setting measured dimension to %d x %d", width, height));
76 | setMeasuredDimension(width, width * ratioHeight / ratioWidth);
77 | } else {
78 | Log.d(TAG, String.format("setting measured dimension to %d x %d", width, height));
79 | setMeasuredDimension(height * ratioWidth / ratioHeight, height);
80 | }
81 | }
82 | }
83 | }
84 |
--------------------------------------------------------------------------------
/app/src/main/java/com/googleresearch/capturesync/softwaresync/NetworkHelpers.java:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright 2019 The Google Research Authors.
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package com.googleresearch.capturesync.softwaresync;
18 |
19 | import android.net.wifi.WifiManager;
20 | import java.net.Inet4Address;
21 | import java.net.InetAddress;
22 | import java.net.NetworkInterface;
23 | import java.net.SocketException;
24 | import java.net.UnknownHostException;
25 | import java.util.Collections;
26 | import java.util.List;
27 |
28 | /** Helper functions for determining local IP address and host IP address on the network. */
29 | public final class NetworkHelpers {
30 | private final WifiManager wifiManager;
31 |
32 | /**
33 | * Constructor providing the wifi manager for determining hotspot leader address.
34 | *
35 | * @param wifiManager via '(WifiManager) context.getSystemService(Context.WIFI_SERVICE);''
36 | */
37 | public NetworkHelpers(WifiManager wifiManager) {
38 | this.wifiManager = wifiManager;
39 | }
40 |
41 | /**
42 | * Returns the IP address of the hotspot host. Requires ACCESS_WIFI_STATE permission. Note: This
43 | * may not work on several devices.
44 | *
45 | * @return IP address of the hotspot host.
46 | */
47 | public InetAddress getHotspotServerAddress() throws SocketException, UnknownHostException {
48 | if (wifiManager.isWifiEnabled()) {
49 | // Return the DHCP server address, which is the hotspot ip address.
50 | int serverAddress = wifiManager.getDhcpInfo().serverAddress;
51 | // DhcpInfo integer addresses are Little Endian and InetAddresses.fromInteger() are Big Endian
52 | // so reverse the bytes before converting from Integer.
53 | byte[] addressBytes = {
54 | (byte) (0xff & serverAddress),
55 | (byte) (0xff & (serverAddress >> 8)),
56 | (byte) (0xff & (serverAddress >> 16)),
57 | (byte) (0xff & (serverAddress >> 24))
58 | };
59 | return InetAddress.getByAddress(addressBytes);
60 | }
61 | // If wifi is disabled, then this is the hotspot host, return the local ip address.
62 | return getIPAddress();
63 | }
64 |
65 | /**
66 | * Finds this devices's IPv4 address that is not localhost and not on a dummy interface.
67 | *
68 | * @return the String IP address on success.
69 | * @throws SocketException on failure to find a suitable IP address.
70 | */
71 | public static InetAddress getIPAddress() throws SocketException {
72 | List interfaces = Collections.list(NetworkInterface.getNetworkInterfaces());
73 | for (NetworkInterface intf : interfaces) {
74 | for (InetAddress addr : Collections.list(intf.getInetAddresses())) {
75 | if (!addr.isLoopbackAddress()
76 | && !intf.getName().equals("dummy0")
77 | && addr instanceof Inet4Address) {
78 | return addr;
79 | }
80 | }
81 | }
82 | throw new SocketException("No viable IP Network addresses found.");
83 | }
84 | }
85 |
--------------------------------------------------------------------------------
/app/src/main/java/com/googleresearch/capturesync/softwaresync/SntpListener.java:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright 2019 The Google Research Authors.
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package com.googleresearch.capturesync.softwaresync;
18 |
19 | import android.util.Log;
20 | import java.io.IOException;
21 | import java.net.DatagramPacket;
22 | import java.net.DatagramSocket;
23 | import java.net.SocketTimeoutException;
24 | import java.nio.ByteBuffer;
25 |
26 | /**
27 | * SNTP listener thread, which is expected to only run while the client is waiting for the leader to
28 | * synchronize with it (WAITING_FOR_LEADER state). The {@link SimpleNetworkTimeProtocol} class is
29 | * used by the leader to send the SNTP messages that this thread listens for.
30 | */
31 | public class SntpListener extends Thread {
32 |
33 | private static final String TAG = "SntpListener";
34 | private boolean running;
35 | private final DatagramSocket nptpSocket;
36 | private final int nptpPort;
37 | private final Ticker localClock;
38 |
39 | public SntpListener(Ticker localClock, DatagramSocket nptpSocket, int nptpPort) {
40 | this.localClock = localClock;
41 | this.nptpSocket = nptpSocket;
42 | this.nptpPort = nptpPort;
43 | }
44 |
45 | public void stopRunning() {
46 | running = false;
47 | }
48 |
49 | @Override
50 | public void run() {
51 | running = true;
52 |
53 | Log.w(TAG, "Starting SNTP Listener thread.");
54 |
55 | byte[] buf = new byte[SyncConstants.SNTP_BUFFER_SIZE];
56 | while (running && !nptpSocket.isClosed()) {
57 | DatagramPacket packet = new DatagramPacket(buf, buf.length);
58 | try {
59 | // Listen for PTP messages.
60 | nptpSocket.receive(packet);
61 |
62 | // 2 (B) - Recv UDP message with t0 at time t0'.
63 | long t0r = localClock.read();
64 |
65 | final int longSize = Long.SIZE / Byte.SIZE;
66 |
67 | if (packet.getLength() != longSize) {
68 | Log.e(
69 | TAG,
70 | "Received UDP message with incorrect packet length "
71 | + packet.getLength()
72 | + ", skipping.");
73 | continue;
74 | }
75 |
76 | // 3 (B) - Send UDP message with t0,t0',t1 at time t1.
77 | long t1 = localClock.read();
78 | ByteBuffer buffer = ByteBuffer.allocate(3 * longSize);
79 | buffer.put(packet.getData(), 0, longSize);
80 | buffer.putLong(longSize, t0r);
81 | buffer.putLong(2 * longSize, t1);
82 | byte[] bufferArray = buffer.array();
83 |
84 | // Send SNTP response back.
85 | DatagramPacket response =
86 | new DatagramPacket(bufferArray, bufferArray.length, packet.getAddress(), nptpPort);
87 | nptpSocket.send(response);
88 | } catch (SocketTimeoutException e) {
89 | // It is normal to time out most of the time, continue.
90 | } catch (IOException e) {
91 | if (nptpSocket.isClosed()) {
92 | // Stop here if socket is closed.
93 | return;
94 | }
95 | throw new IllegalStateException("SNTP Thread didn't close gracefully: " + e);
96 | }
97 | }
98 | Log.w(TAG, "SNTP Listener thread finished.");
99 | }
100 | }
101 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/activity_main.xml:
--------------------------------------------------------------------------------
1 |
10 |
11 |
15 |
16 |
20 |
21 |
22 |
23 |
27 |
28 |
33 |
34 |
40 |
41 |
42 |
43 |
48 |
53 |
54 |
60 |
61 |
66 |
67 |
73 |
74 |
80 |
81 |
87 |
88 |
94 |
95 |
101 |
102 |
103 |
104 |
105 |
106 |
107 |
--------------------------------------------------------------------------------
/app/src/main/java/com/googleresearch/capturesync/softwaresync/phasealign/PhaseConfig.java:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright 2019 The Google Research Authors.
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package com.googleresearch.capturesync.softwaresync.phasealign;
18 |
19 | import org.json.JSONException;
20 | import org.json.JSONObject;
21 |
22 | /**
23 | * A unique PhaseConfig is associated with a type of mobile device such as a Pixel 3, and a specific
24 | * capture mode, such as for a repeating request with exposures less than 33 ms.
25 | */
26 | public final class PhaseConfig {
27 | private final long periodNs;
28 | private final long goalPhaseNs;
29 | private final long alignThresholdNs;
30 | private final long overheadNs;
31 | private final long minExposureNs;
32 |
33 | private PhaseConfig(
34 | long periodNs, long goalPhaseNs, long alignThresholdNs, long overheadNs, long minExposureNs) {
35 | this.periodNs = periodNs;
36 | this.goalPhaseNs = goalPhaseNs;
37 | this.alignThresholdNs = alignThresholdNs;
38 | this.overheadNs = overheadNs;
39 | this.minExposureNs = minExposureNs;
40 | }
41 |
42 | /** Parse from a given JSON. */
43 | public static PhaseConfig parseFromJSON(JSONObject json) throws JSONException {
44 | if (!json.has("periodNs")) {
45 | throw new IllegalArgumentException("Missing PeriodNs in JSON.");
46 | }
47 | if (!json.has("goalPhaseNs")) {
48 | throw new IllegalArgumentException("Missing GoalPhaseNs in JSON.");
49 | }
50 | if (!json.has("alignThresholdNs")) {
51 | throw new IllegalArgumentException("Missing AlignThresholdNs in JSON.");
52 | }
53 | if (!json.has("overheadNs")) {
54 | throw new IllegalArgumentException("Missing OverheadNs in JSON.");
55 | }
56 | if (!json.has("minExposureNs")) {
57 | throw new IllegalArgumentException("Missing MinExposureNs in JSON.");
58 | }
59 | return new PhaseConfig(
60 | json.getLong("periodNs"),
61 | json.getLong("goalPhaseNs"),
62 | json.getLong("alignThresholdNs"),
63 | json.getLong("overheadNs"),
64 | json.getLong("minExposureNs"));
65 | }
66 |
67 | /**
68 | * Nominal period between two frames in the image sequence. This is usually very close to the
69 | * `SENSOR_FRAME_DURATION`. The period is assumed to be constant for the duration of phase
70 | * alignment. ie. generally only in situations where exposure is < 33ms. If the exposure is fixed
71 | * at a longer exposure, it may also work with a tuned config.
72 | */
73 | public long periodNs() {
74 | return periodNs;
75 | }
76 |
77 | /* The target phase to align to, usually chosen as half the period. */
78 | public long goalPhaseNs() {
79 | return goalPhaseNs;
80 | }
81 |
82 | /* The threshold on the absolute difference between the goal and current
83 | * phases to be considered aligned. */
84 | public long alignThresholdNs() {
85 | return alignThresholdNs;
86 | }
87 |
88 | /**
89 | * Measured average difference between frame duration and sensor exposure time for exposures
90 | * greater than 33ms.
91 | */
92 | public long overheadNs() {
93 | return overheadNs;
94 | }
95 |
96 | /* Lower bound sensor exposure time that still causes a phase shift.
97 | * This is usually slightly less than the period by the overhead. In practice
98 | * this must be tuned. */
99 | public long minExposureNs() {
100 | return minExposureNs;
101 | }
102 |
103 | public String toString() {
104 | return "PhaseConfig{"
105 | + "periodNs="
106 | + periodNs
107 | + ", "
108 | + "goalPhaseNs="
109 | + goalPhaseNs
110 | + ", "
111 | + "alignThresholdNs="
112 | + alignThresholdNs
113 | + ", "
114 | + "overheadNs="
115 | + overheadNs
116 | + ", "
117 | + "minExposureNs="
118 | + minExposureNs
119 | + "}";
120 | }
121 | }
122 |
--------------------------------------------------------------------------------
/app/src/main/java/com/googleresearch/capturesync/CaptureRequestFactory.java:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright 2019 The Google Research Authors.
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package com.googleresearch.capturesync;
18 |
19 | import static android.hardware.camera2.CameraDevice.TEMPLATE_PREVIEW;
20 | import static android.hardware.camera2.CameraMetadata.CONTROL_AE_MODE_OFF;
21 | import static android.hardware.camera2.CameraMetadata.CONTROL_AWB_MODE_AUTO;
22 | import static android.hardware.camera2.CameraMetadata.CONTROL_MODE_AUTO;
23 | import static android.hardware.camera2.CaptureRequest.CONTROL_AE_MODE;
24 | import static android.hardware.camera2.CaptureRequest.CONTROL_AF_MODE;
25 | import static android.hardware.camera2.CaptureRequest.CONTROL_AF_MODE_CONTINUOUS_PICTURE;
26 | import static android.hardware.camera2.CaptureRequest.CONTROL_AWB_MODE;
27 | import static android.hardware.camera2.CaptureRequest.CONTROL_MODE;
28 | import static android.hardware.camera2.CaptureRequest.SENSOR_EXPOSURE_TIME;
29 | import static android.hardware.camera2.CaptureRequest.SENSOR_SENSITIVITY;
30 |
31 | import android.hardware.camera2.CameraAccessException;
32 | import android.hardware.camera2.CameraDevice;
33 | import android.hardware.camera2.CaptureRequest;
34 | import android.view.Surface;
35 | import com.googleresearch.capturesync.ImageMetadataSynchronizer.CaptureRequestTag;
36 | import java.util.ArrayList;
37 | import java.util.List;
38 |
39 | /** Helper class for creating common {@link CaptureRequest.Builder} instances. */
40 | public class CaptureRequestFactory {
41 |
42 | private final CameraDevice device;
43 |
44 | public CaptureRequestFactory(CameraDevice camera) {
45 | device = camera;
46 | }
47 |
48 | /**
49 | * Makes a {@link CaptureRequest.Builder} for the viewfinder preview. This always adds the
50 | * viewfinder.
51 | */
52 | public CaptureRequest.Builder makePreview(
53 | Surface viewfinderSurface,
54 | List imageSurfaces,
55 | long sensorExposureTimeNs,
56 | int sensorSensitivity)
57 | throws CameraAccessException {
58 |
59 | CaptureRequest.Builder builder = device.createCaptureRequest(TEMPLATE_PREVIEW);
60 | // Manually set exposure and sensitivity using UI sliders on the leader.
61 | builder.set(CONTROL_AE_MODE, CONTROL_AE_MODE_OFF);
62 | builder.set(SENSOR_EXPOSURE_TIME, sensorExposureTimeNs);
63 | builder.set(SENSOR_SENSITIVITY, sensorSensitivity);
64 |
65 | // Auto white balance used, these could be locked and sent from the leader instead.
66 | builder.set(CONTROL_AWB_MODE, CONTROL_AWB_MODE_AUTO);
67 |
68 | // Auto focus is used since different devices may have different manual focus values.
69 | builder.set(CONTROL_AF_MODE, CONTROL_AF_MODE_CONTINUOUS_PICTURE);
70 |
71 | if (viewfinderSurface != null) {
72 | builder.addTarget(viewfinderSurface);
73 | }
74 | List targetIndices = new ArrayList<>();
75 | for (int i = 0; i < imageSurfaces.size(); i++) {
76 | builder.addTarget(imageSurfaces.get(i));
77 | targetIndices.add(i);
78 | }
79 | builder.setTag(new CaptureRequestTag(targetIndices, null));
80 | return builder;
81 | }
82 |
83 | public CaptureRequest.Builder makeFrameInjectionRequest(
84 | long desiredExposureTimeNs, List imageSurfaces) throws CameraAccessException {
85 | CaptureRequest.Builder builder = device.createCaptureRequest(TEMPLATE_PREVIEW);
86 | builder.set(CONTROL_MODE, CONTROL_MODE_AUTO);
87 | builder.set(CONTROL_AE_MODE, CONTROL_AE_MODE_OFF);
88 | builder.set(SENSOR_EXPOSURE_TIME, desiredExposureTimeNs);
89 | // TODO: Inserting frame duration directly would be more accurate than inserting exposure since
90 | // {@code frame duration ~ exposure + variable overhead}. However setting frame duration may not
91 | // be supported on many android devices, so we use exposure time here.
92 |
93 | List targetIndices = new ArrayList<>();
94 | for (int i = 0; i < imageSurfaces.size(); i++) {
95 | builder.addTarget(imageSurfaces.get(i));
96 | targetIndices.add(i);
97 | }
98 | builder.setTag(new CaptureRequestTag(targetIndices, PhaseAlignController.INJECT_FRAME));
99 |
100 | return builder;
101 | }
102 | }
103 |
--------------------------------------------------------------------------------
/app/src/main/java/com/googleresearch/capturesync/softwaresync/phasealign/PhaseResponse.java:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright 2019 The Google Research Authors.
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package com.googleresearch.capturesync.softwaresync.phasealign;
18 |
19 | /** PhaseAligner response providing image stream phase alignment results. */
20 | public final class PhaseResponse {
21 | private final long phaseNs;
22 | private final long exposureTimeToShiftNs;
23 | private final long frameDurationToShiftNs;
24 | private final long diffFromGoalNs;
25 | private final boolean isAligned;
26 |
27 | private PhaseResponse(
28 | long phaseNs,
29 | long exposureTimeToShiftNs,
30 | long frameDurationToShiftNs,
31 | long diffFromGoalNs,
32 | boolean isAligned) {
33 | this.phaseNs = phaseNs;
34 | this.exposureTimeToShiftNs = exposureTimeToShiftNs;
35 | this.frameDurationToShiftNs = frameDurationToShiftNs;
36 | this.diffFromGoalNs = diffFromGoalNs;
37 | this.isAligned = isAligned;
38 | }
39 |
40 | static Builder builder() {
41 | return new PhaseResponse.Builder();
42 | }
43 |
44 | /** Builder for new PhaseResponses. All parameters are required. */
45 | static final class Builder {
46 | private Long phaseNs;
47 | private Long exposureTimeToShiftNs;
48 | private Long frameDurationToShiftNs;
49 | private Long diffFromGoalNs;
50 | private Boolean isAligned;
51 |
52 | Builder() {}
53 |
54 | Builder setPhaseNs(long phaseNs) {
55 | this.phaseNs = phaseNs;
56 | return this;
57 | }
58 |
59 | Builder setExposureTimeToShiftNs(long exposureTimeToShiftNs) {
60 | this.exposureTimeToShiftNs = exposureTimeToShiftNs;
61 | return this;
62 | }
63 |
64 | Builder setFrameDurationToShiftNs(long frameDurationToShiftNs) {
65 | this.frameDurationToShiftNs = frameDurationToShiftNs;
66 | return this;
67 | }
68 |
69 | Builder setDiffFromGoalNs(long diffFromGoalNs) {
70 | this.diffFromGoalNs = diffFromGoalNs;
71 | return this;
72 | }
73 |
74 | Builder setIsAligned(boolean isAligned) {
75 | this.isAligned = isAligned;
76 | return this;
77 | }
78 |
79 | PhaseResponse build() {
80 | String missing = "";
81 | if (this.phaseNs == null) {
82 | missing += " phaseNs";
83 | }
84 | if (this.exposureTimeToShiftNs == null) {
85 | missing += " exposureTimeToShiftNs";
86 | }
87 | if (this.frameDurationToShiftNs == null) {
88 | missing += " frameDurationToShiftNs";
89 | }
90 | if (this.diffFromGoalNs == null) {
91 | missing += " diffFromGoalNs";
92 | }
93 | if (this.isAligned == null) {
94 | missing += " isAligned";
95 | }
96 |
97 | if (!missing.isEmpty()) {
98 | throw new IllegalStateException("Missing required properties:" + missing);
99 | }
100 |
101 | return new PhaseResponse(
102 | this.phaseNs,
103 | this.exposureTimeToShiftNs,
104 | this.frameDurationToShiftNs,
105 | this.diffFromGoalNs,
106 | this.isAligned);
107 | }
108 | }
109 |
110 | /** The measured phase in this response. */
111 | public long phaseNs() {
112 | return phaseNs;
113 | }
114 |
115 | /**
116 | * Estimated sensor exposure time needed in an inserted frame to align the phase to the goal
117 | * phase. This should be used to set CaptureRequest.SENSOR_EXPOSURE_TIME. This currently overrides
118 | * setting frame duration and is the only method for introducing phase shift.
119 | */
120 | public long exposureTimeToShiftNs() {
121 | return exposureTimeToShiftNs;
122 | }
123 |
124 | /** Difference between the goal phase and the phase in this response. */
125 | public long diffFromGoalNs() {
126 | return diffFromGoalNs;
127 | }
128 |
129 | /** True if the current phase is within the threshold of the goal phase. */
130 | public boolean isAligned() {
131 | return isAligned;
132 | }
133 |
134 | @Override
135 | public String toString() {
136 | return String.format(
137 | "PhaseResponse{phaseNs=%d, exposureTimeToShiftNs=%d, frameDurationToShiftNs=%d,"
138 | + " diffFromGoalNs=%d, isAligned=%s}",
139 | phaseNs, exposureTimeToShiftNs, frameDurationToShiftNs, diffFromGoalNs, isAligned);
140 | }
141 | }
142 |
--------------------------------------------------------------------------------
/app/src/main/java/com/googleresearch/capturesync/softwaresync/phasealign/PhaseAligner.java:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright 2019 The Google Research Authors.
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package com.googleresearch.capturesync.softwaresync.phasealign;
18 |
19 | /**
20 | * Calculates the current camera phase and returns the necessary exposure offset needed to align.
21 | * Runs in the clock domain of the timestamps passed in to `passTimestamp`.
22 | *
23 | *
Note: A single instance of PhaseAligner expects a constant period. Generally this will be for
24 | * an image stream with a constant frame duration and therefore a constant period and phase. This is
25 | * normally found for exposures under 33ms, where frame duration is clamped to 33.33ms. PhaseAligner
26 | * expects the user to have a fixed frame duration and associated period when running phase
27 | * alignment.
28 | *
29 | *
Users are expected to instantiate PhaseAligner instances from configuration files specific to
30 | * their device hardware using `newWithJSONConfig(...)`, or manually creating a config using
31 | * `loadFromConfig()`.
32 | *
33 | *
Users pass timestamps from the image sequence and are returned a PhaseResponse, which provides
34 | * both the current phase state and the estimated exposure and frame duration needed to align. By
35 | * inserting a new frame into the sequence with the estimated exposure and/or frame duration the
36 | * sequence should align closer to the desired phase. Given that the actual frame duration of the
37 | * inserted frame may be different from the estimated, several iterations may be required, and the
38 | * user can check to stope via when the phase response is aligned with `isAligned`.
39 | */
40 | public final class PhaseAligner {
41 | private final PhaseConfig config;
42 |
43 | /** Instantiate phase aligner using configuration options from a PhaseConfig proto. */
44 | public PhaseAligner(PhaseConfig config) {
45 | this.config = config;
46 | }
47 |
48 | /**
49 | * Given the latest image sequence timestamp, Responds with phase alignment information.
50 | *
51 | *
The response contains an estimated sensor exposure time or frame duration needed to align
52 | * align future frames to the desired goal phase, as well as the current alignment state.
53 | *
54 | * @param timestampNs timestamp in the same clock domain as used in the phase configuration. This
55 | * can be either the local clock domain or the software synchronized leader clock domain, as
56 | * long as it stays consistent for the duration of the phase aligner instance.
57 | * @return PhaseResponse containing the current phase state as well as the estimated sensor
58 | * exposure time and frame duration needed to align.
59 | */
60 | public final PhaseResponse passTimestamp(long timestampNs) {
61 | long phaseNs = timestampNs % config.periodNs();
62 | long diffFromGoalNs = config.goalPhaseNs() - phaseNs;
63 | boolean isAligned = Math.abs(diffFromGoalNs) < config.alignThresholdNs();
64 |
65 | /* Stop early if already aligned. */
66 | if (isAligned) {
67 | return PhaseResponse.builder()
68 | .setPhaseNs(phaseNs)
69 | .setExposureTimeToShiftNs(0)
70 | .setFrameDurationToShiftNs(0)
71 | .setDiffFromGoalNs(diffFromGoalNs)
72 | .setIsAligned(isAligned)
73 | .build();
74 | }
75 |
76 | /* Since we can only shift phase into the future, shift negative offsets over by one period. */
77 | long desiredPhaseOffsetNs = diffFromGoalNs;
78 | if (diffFromGoalNs < 0) {
79 | desiredPhaseOffsetNs += config.periodNs();
80 | }
81 |
82 | /*
83 | * Calculate the frame duration needed to align to the `goalPhaseNs`, using the linear
84 | * relationship between offset and phase shift. Since durations <= period have no effect, add
85 | * another period to the duration.
86 | */
87 | long frameDurationNsToShift = desiredPhaseOffsetNs / 2 + config.periodNs();
88 |
89 | /*
90 | * Convert to estimated shift exposure time by removing the average overheadNs. Note: The
91 | * majority of noise in phase alignment is due to this varying estimated overheadNs.
92 | *
93 | *
Due to the indirect control of frame duration, choosing offsets <= minExposure causes no
94 | * change in phase. To avoid this, a minimum offset is manually chosen for the specific device
95 | * architecture.
96 | */
97 | long exposureTimeNsToShift =
98 | Math.max(config.minExposureNs(), frameDurationNsToShift - config.overheadNs());
99 |
100 | return PhaseResponse.builder()
101 | .setPhaseNs(phaseNs)
102 | .setExposureTimeToShiftNs(exposureTimeNsToShift)
103 | .setFrameDurationToShiftNs(frameDurationNsToShift)
104 | .setDiffFromGoalNs(diffFromGoalNs)
105 | .setIsAligned(isAligned)
106 | .build();
107 | }
108 |
109 | /** Returns the configuration options used to set up the phase aligner. */
110 | public final PhaseConfig getConfig() {
111 | return config;
112 | }
113 | }
114 |
--------------------------------------------------------------------------------
/app/src/main/java/com/googleresearch/capturesync/PhaseAlignController.java:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright 2019 The Google Research Authors.
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package com.googleresearch.capturesync;
18 |
19 | import android.os.Handler;
20 | import android.util.Log;
21 | import com.googleresearch.capturesync.softwaresync.phasealign.PhaseAligner;
22 | import com.googleresearch.capturesync.softwaresync.phasealign.PhaseConfig;
23 | import com.googleresearch.capturesync.softwaresync.phasealign.PhaseResponse;
24 |
25 | /**
26 | * Calculates and adjusts camera phase by inserting frames of varying exposure lengths.
27 | *
28 | *
Phase alignment is an iterative process. Running for more iterations results in higher
29 | * accuracy up to the stability of the camera and the accuracy of the phase alignment configuration
30 | * values.
31 | */
32 | public class PhaseAlignController {
33 | public static final String INJECT_FRAME = "injection_frame";
34 | private static final String TAG = "PhaseAlignController";
35 |
36 | // Maximum number of phase alignment iteration steps in the alignment process.
37 | // TODO(samansari): Make this a parameter that you pass in to this class. Then make the class that
38 | // constructs this pass the constant in.
39 | private static final int MAX_ITERATIONS = 5;
40 | // Delay after an alignment step to wait for phase to settle before starting the next iteration.
41 | private static final int PHASE_SETTLE_DELAY_MS = 400;
42 | private final MainActivity context;
43 |
44 | private final Handler handler;
45 | private final Object lock = new Object();
46 | private boolean inAlignState = false;
47 |
48 | private final PhaseAligner phaseAligner;
49 | private PhaseResponse latestResponse;
50 |
51 | public PhaseAlignController(PhaseConfig config, MainActivity context) {
52 | handler = new Handler();
53 | phaseAligner = new PhaseAligner(config);
54 | Log.v(TAG, "Loaded phase align config.");
55 | this.context = context;
56 | }
57 |
58 | /**
59 | * Update the latest phase response from the latest frame timestamp to keep track of phase.
60 | *
61 | *
The timestamp is nanoseconds in the synchronized leader clock domain.
62 | *
63 | * @return phase of timestamp in nanoseconds in the same domain as given.
64 | */
65 | public long updateCaptureTimestamp(long timestampNs) {
66 | // TODO(samansaari) : Rename passTimestamp -> updateCaptureTimestamp or similar in softwaresync.
67 | latestResponse = phaseAligner.passTimestamp(timestampNs);
68 | // TODO (samansari) : Pull this into an interface/callback.
69 | context.runOnUiThread(() -> context.updatePhaseTextView(latestResponse.diffFromGoalNs()));
70 | return latestResponse.phaseNs();
71 | }
72 |
73 | /** Submit an frame with a specific exposure to offset future frames and align phase. */
74 | private void doPhaseAlignStep() {
75 | Log.i(
76 | TAG,
77 | String.format(
78 | "Current Phase: %.3f ms, Diff: %.3f ms, inserting frame exposure %.6f ms, lower bound"
79 | + " %.6f ms.",
80 | latestResponse.phaseNs() * 1e-6f,
81 | latestResponse.diffFromGoalNs() * 1e-6f,
82 | latestResponse.exposureTimeToShiftNs() * 1e-6f,
83 | phaseAligner.getConfig().minExposureNs() * 1e-6f));
84 |
85 | // TODO(samansari): Make this an interface.
86 | context.injectFrame(latestResponse.exposureTimeToShiftNs());
87 | }
88 |
89 | public void startAlign() {
90 | synchronized (lock) {
91 | if (inAlignState) {
92 | Log.i(TAG, "startAlign() called while already aligning.");
93 | return;
94 | }
95 | inAlignState = true;
96 | // Start inserting frames every {@code PHASE_SETTLE_DELAY_MS} ms to try and push the phase to
97 | // the goal phase. Stop after aligned to threshold or after {@code MAX_ITERATIONS}.
98 | handler.post(() -> work(MAX_ITERATIONS));
99 | }
100 | }
101 |
102 | private void work(int iterationsLeft) {
103 | // Check if Aligned / Not Aligned but able to iterate / Ran out of iterations.
104 | if (latestResponse.isAligned()) { // Aligned.
105 | Log.i(
106 | TAG,
107 | String.format(
108 | "Reached: Current Phase: %.3f ms, Diff: %.3f ms",
109 | latestResponse.phaseNs() * 1e-6f, latestResponse.diffFromGoalNs() * 1e-6f));
110 | synchronized (lock) {
111 | inAlignState = false;
112 | }
113 |
114 | Log.d(TAG, "Aligned.");
115 | } else if (!latestResponse.isAligned() && iterationsLeft > 0) {
116 | // Not aligned but able to run another alignment iteration.
117 | doPhaseAlignStep();
118 | Log.v(TAG, "Queued another phase align step.");
119 | // TODO (samansari) : Replace this brittle delay-based solution to a response-based one.
120 | handler.postDelayed(
121 | () -> work(iterationsLeft - 1), PHASE_SETTLE_DELAY_MS); // Try again after it settles.
122 | } else { // Reached max iterations before aligned.
123 | Log.i(
124 | TAG,
125 | String.format(
126 | "Failed to Align, Stopping at: Current Phase: %.3f ms, Diff: %.3f ms",
127 | latestResponse.phaseNs() * 1e-6f, latestResponse.diffFromGoalNs() * 1e-6f));
128 | synchronized (lock) {
129 | inAlignState = false;
130 | }
131 | Log.d(TAG, "Finishing alignment, reached max iterations.");
132 | }
133 | }
134 | }
135 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_launcher_background.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
10 |
15 |
20 |
25 |
30 |
35 |
40 |
45 |
50 |
55 |
60 |
65 |
70 |
75 |
80 |
85 |
90 |
95 |
100 |
105 |
110 |
115 |
120 |
125 |
130 |
135 |
140 |
145 |
150 |
155 |
160 |
165 |
170 |
171 |
--------------------------------------------------------------------------------
/app/src/main/java/com/googleresearch/capturesync/CameraController.java:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright 2019 The Google Research Authors.
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package com.googleresearch.capturesync;
18 |
19 | import android.graphics.ImageFormat;
20 | import android.hardware.camera2.CameraCaptureSession.CaptureCallback;
21 | import android.hardware.camera2.CameraCharacteristics;
22 | import android.hardware.camera2.CameraDevice;
23 | import android.hardware.camera2.CaptureResult;
24 | import android.media.ImageReader;
25 | import android.os.Handler;
26 | import android.os.HandlerThread;
27 | import android.util.Log;
28 | import android.util.Size;
29 | import android.view.Surface;
30 | import com.googleresearch.capturesync.ImageMetadataSynchronizer.CaptureRequestTag;
31 | import com.googleresearch.capturesync.softwaresync.TimeDomainConverter;
32 | import com.googleresearch.capturesync.softwaresync.TimeUtils;
33 | import java.text.SimpleDateFormat;
34 | import java.util.ArrayList;
35 | import java.util.List;
36 | import java.util.TimeZone;
37 |
38 | /** High level camera controls. */
39 | public class CameraController {
40 | private static final String TAG = "CameraController";
41 |
42 | // Thread on which to receive ImageReader callbacks.
43 | private HandlerThread imageThread;
44 | private Handler imageHandler;
45 |
46 | // Thread on which to receive Synchronized results.
47 | private HandlerThread syncThread;
48 | private Handler syncHandler;
49 |
50 | // ImageReaders, which puts the images into
51 | // their respective queues.
52 | private final List imageReaders;
53 |
54 | private final ImageMetadataSynchronizer imageMetadataSynchronizer;
55 |
56 | private final ResultProcessor resultProcessor;
57 |
58 | public CaptureCallback getSynchronizerCaptureCallback() {
59 | return imageMetadataSynchronizer.getCaptureCallback();
60 | }
61 |
62 | /**
63 | * Camera frames come in continuously and are thrown away. When a desired timestamp {@code
64 | * goalSynchronizedTimestampNs} is set, the first frame with synchronized timestamp at or after
65 | * the desired timestamp is saved to disk.
66 | */
67 | private long goalSynchronizedTimestampNs;
68 |
69 | private String goalOutputDirName;
70 |
71 | private CaptureRequestFactory requestFactory;
72 |
73 | /**
74 | * Constructs the high level CameraController object.
75 | *
76 | *
If {@code rawImageResolution} is not null, it will create an ImageReader for the raw stream
77 | * and stream frames to it. If {@code yuvImageResolution} is not null, it will create an
78 | * ImageReader for the yuv stream and stream frames to it. If {@code viewfinderSurface} is not
79 | * null, it will stream frames to it.
80 | */
81 | public CameraController(
82 | CameraCharacteristics cameraCharacteristics,
83 | Size rawImageResolution,
84 | Size yuvImageResolution,
85 | PhaseAlignController phaseAlignController,
86 | MainActivity context,
87 | TimeDomainConverter timeDomainConverter) {
88 |
89 | imageThread = new HandlerThread("ImageThread");
90 | imageThread.start();
91 | imageHandler = new Handler(imageThread.getLooper());
92 |
93 | syncThread = new HandlerThread("SyncThread");
94 | syncThread.start();
95 | syncHandler = new Handler(syncThread.getLooper());
96 |
97 | resultProcessor =
98 | new ResultProcessor(
99 | timeDomainConverter, context, Constants.SAVE_JPG_FROM_YUV, Constants.JPG_QUALITY);
100 |
101 | imageReaders = new ArrayList<>();
102 | final int imageBuffer = 1;
103 | if (rawImageResolution != null) {
104 | imageReaders.add(
105 | ImageReader.newInstance(
106 | rawImageResolution.getWidth(),
107 | rawImageResolution.getHeight(),
108 | ImageFormat.RAW10,
109 | imageBuffer));
110 | }
111 |
112 | if (yuvImageResolution != null) {
113 | imageReaders.add(
114 | ImageReader.newInstance(
115 | yuvImageResolution.getWidth(),
116 | yuvImageResolution.getHeight(),
117 | ImageFormat.YUV_420_888,
118 | imageBuffer));
119 | }
120 |
121 | imageMetadataSynchronizer = new ImageMetadataSynchronizer(imageReaders, imageHandler);
122 | imageMetadataSynchronizer.registerCallback(
123 | output -> {
124 | CaptureResult result = output.result;
125 | Object userTag = CaptureRequestTag.getUserTag(result);
126 | if (userTag != null && userTag.equals(PhaseAlignController.INJECT_FRAME)) {
127 | Log.v(TAG, "Skipping phase align injection frame.");
128 | output.close();
129 | return;
130 | }
131 |
132 | int sequenceId = result.getSequenceId();
133 | long synchronizedTimestampNs =
134 | timeDomainConverter.leaderTimeForLocalTimeNs(
135 | result.get(CaptureResult.SENSOR_TIMESTAMP));
136 |
137 | double timestampMs = TimeUtils.nanosToMillis((double) synchronizedTimestampNs);
138 | double frameDurationMs =
139 | TimeUtils.nanosToMillis((double) result.get(CaptureResult.SENSOR_FRAME_DURATION));
140 |
141 | long phaseNs = phaseAlignController.updateCaptureTimestamp(synchronizedTimestampNs);
142 | double phaseMs = TimeUtils.nanosToMillis((double) phaseNs);
143 |
144 | Log.v(
145 | TAG,
146 | String.format(
147 | "onCaptureCompleted: timestampMs = %,.3f, frameDurationMs = %,.6f, phase ="
148 | + " %,.3f, sequence id = %d",
149 | timestampMs, frameDurationMs, phaseMs, sequenceId));
150 |
151 | if (shouldSaveFrame(synchronizedTimestampNs)) {
152 | Log.d(TAG, "Sync frame found! Committing and processing");
153 | Frame frame = new Frame(result, output);
154 | resultProcessor.submitProcessRequest(frame, goalOutputDirName);
155 | resetGoal();
156 | } else {
157 | output.close();
158 | }
159 | },
160 | syncHandler);
161 | }
162 |
163 | /* Check if given timestamp is or passed goal timestamp in the synchronized leader time domain. */
164 | private boolean shouldSaveFrame(long synchronizedTimestampNs) {
165 | return goalSynchronizedTimestampNs != 0
166 | && synchronizedTimestampNs >= goalSynchronizedTimestampNs;
167 | }
168 |
169 | private void resetGoal() {
170 | goalSynchronizedTimestampNs = 0;
171 | }
172 |
173 | public List getOutputSurfaces() {
174 | List surfaces = new ArrayList<>();
175 | for (ImageReader reader : imageReaders) {
176 | surfaces.add(reader.getSurface());
177 | }
178 | return surfaces;
179 | }
180 |
181 | public void configure(CameraDevice device) {
182 | requestFactory = new CaptureRequestFactory(device);
183 | }
184 |
185 | public void close() {
186 | imageMetadataSynchronizer.close();
187 |
188 | imageThread.quitSafely();
189 | try {
190 | imageThread.join();
191 | imageThread = null;
192 | imageHandler = null;
193 | } catch (InterruptedException e) {
194 | Log.e(TAG, "Failed to join imageThread");
195 | }
196 |
197 | syncThread.quitSafely();
198 | try {
199 | syncThread.join();
200 | syncThread = null;
201 | syncHandler = null;
202 | } catch (InterruptedException e) {
203 | Log.e(TAG, "Failed to join syncThread");
204 | }
205 | }
206 |
207 | public CaptureRequestFactory getRequestFactory() {
208 | return requestFactory;
209 | }
210 |
211 | // Input desired capture time in leader time domain (first frame that >= that timestamp).
212 | public void setUpcomingCaptureStill(long desiredSynchronizedCaptureTimeNs) {
213 | goalOutputDirName = getTimeStr(desiredSynchronizedCaptureTimeNs);
214 | goalSynchronizedTimestampNs = desiredSynchronizedCaptureTimeNs;
215 | Log.i(
216 | TAG,
217 | String.format(
218 | "Request sync still at %d to %s", goalSynchronizedTimestampNs, goalOutputDirName));
219 | }
220 |
221 | private String getTimeStr(long timestampNs) {
222 | SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyyMMdd_HHmmss_SSS");
223 | simpleDateFormat.setTimeZone(TimeZone.getDefault());
224 | return simpleDateFormat.format(timestampNs / 1_000_000L);
225 | }
226 | }
227 |
--------------------------------------------------------------------------------
/app/src/main/java/com/googleresearch/capturesync/softwaresync/SoftwareSyncClient.java:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright 2019 The Google Research Authors.
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package com.googleresearch.capturesync.softwaresync;
18 |
19 | import android.util.Log;
20 | import java.io.IOException;
21 | import java.net.InetAddress;
22 | import java.util.Map;
23 | import java.util.concurrent.Executors;
24 | import java.util.concurrent.ScheduledExecutorService;
25 | import java.util.concurrent.TimeUnit;
26 |
27 | /**
28 | * Client which registers and synchronizes clocks with SoftwareSyncLeader. This allows it to receive
29 | * messages and timestamps at which to simultaneously process commands such as capturing a photo at
30 | * the same time as the leader device.
31 | *
32 | *
The client can be in one of internal three states: Waiting for leader, registered but not
33 | * synced, and synced.
34 | *
35 | *
On instantiation, the client attempts to register with the leader, and is waiting for the
36 | * leader to respond.
37 | *
38 | *
Then, once it is registered but not yet synced, it requests SNTP synchronization with the
39 | * leader. Here it listens for offsetNs update rpc messages and sets it's offsetNs to that, thereby
40 | * synchronizing it's clock with leader to the precision requested.
41 | *
42 | *
Finally, once the leader responds with an time correction offsetNs, it enters the synced
43 | * state.
44 | *
45 | *
>User should handle thrown IOExceptions for networking. The most common cause for a thrown
46 | * exception is when the user closes the client down, but there is still a socket receive or a
47 | * periodic heartbeat send still in progress.
48 | */
49 | public class SoftwareSyncClient extends SoftwareSyncBase {
50 |
51 | /** Tracks the state of client synchronization with the leader. */
52 | private boolean synced;
53 |
54 | private final Object syncLock = new Object();
55 | private final ScheduledExecutorService heartbeatScheduler = Executors.newScheduledThreadPool(1);
56 |
57 | /**
58 | * Time of last leader response received in the clock domain of the leader's
59 | * SystemClock.elapsedRealTimeNanos().
60 | */
61 | private long lastLeaderResponseTimeNs;
62 |
63 | private long lastLeaderOffsetResponseTimeNs;
64 |
65 | private SntpListener sntpThread;
66 |
67 | public SoftwareSyncClient(
68 | String name,
69 | InetAddress address,
70 | InetAddress leaderAddress,
71 | Map rpcCallbacks) {
72 | this(name, new SystemTicker(), address, leaderAddress, rpcCallbacks);
73 | }
74 |
75 | @SuppressWarnings("FutureReturnValueIgnored")
76 | private SoftwareSyncClient(
77 | String name,
78 | Ticker localClock,
79 | InetAddress address,
80 | InetAddress leaderAddress,
81 | Map rpcCallbacks) {
82 | super(name, localClock, address, leaderAddress);
83 |
84 | // Add client-specific RPC callbacks.
85 | rpcMap.put(
86 | SyncConstants.METHOD_HEARTBEAT_ACK,
87 | payload -> {
88 | // Leader responded to heartbeat. update last response and change sync status as needed.
89 | lastLeaderResponseTimeNs = localClock.read();
90 | Log.v(TAG, "Heartbeat acknowledge received from leader.");
91 | updateState();
92 | });
93 | rpcMap.put(
94 | SyncConstants.METHOD_OFFSET_UPDATE,
95 | payload -> {
96 | lastLeaderOffsetResponseTimeNs = localClock.read();
97 |
98 | Log.d(TAG, "Received offsetNs update: (" + payload + "), stopping sntp sync request.");
99 | // Set the time offsetNs to the offsetNs passed in by the leader and update state.
100 | setLeaderFromLocalNs(Long.parseLong(payload));
101 | updateState();
102 | onRpc(SyncConstants.METHOD_MSG_OFFSET_UPDATED, Long.toString(getLeaderFromLocalNs()));
103 | });
104 |
105 | // Add callbacks passed by user.
106 | addPublicRpcCallbacks(rpcCallbacks);
107 |
108 | // Initial state is waiting to register with leader.
109 | reset();
110 |
111 | // Start periodically sending out a heartbeat to the leader.
112 | heartbeatScheduler.scheduleAtFixedRate(
113 | this::sendHeartbeat, 0, SyncConstants.HEARTBEAT_PERIOD_NS, TimeUnit.NANOSECONDS);
114 | }
115 |
116 | /* Resets the client synchronization state. */
117 | private void reset() {
118 | lastLeaderResponseTimeNs = 0;
119 | lastLeaderOffsetResponseTimeNs = 0;
120 | maybeStartSntpThread();
121 | updateState();
122 | }
123 |
124 | /**
125 | * Sends a heartbeat to the leader and waits for an acknowledge response. If there is a response,
126 | * it updates the last known leader response time. It then calls updateState() to see if there is
127 | * a state transition needed. It also posts a delayed request to heartbeatHandler to run this
128 | * again in HEARTBEAT_TIME_MS.
129 | */
130 | private void sendHeartbeat() {
131 | // First update current client state based on time since last response.
132 | updateState();
133 |
134 | // Generate heartbeat message containing the client address and the
135 | // string value of the synchronization state.
136 | final String heartbeatMsg;
137 | synchronized (syncLock) {
138 | // Note: Send messages using strings for simplicity.
139 | heartbeatMsg =
140 | String.format(
141 | "%s,%s,%s",
142 | getLocalClientInfo().name(),
143 | getLocalClientInfo().address().getHostAddress(),
144 | Boolean.toString(synced));
145 | }
146 |
147 | // Send heartbeat RPC to leader, expecting a METHOD_HEARTBEAT_ACK rpc back from leader.
148 | sendRpc(SyncConstants.METHOD_HEARTBEAT, heartbeatMsg, getLeaderAddress());
149 | }
150 |
151 | /**
152 | * Propagate state machine depending on lastLeaderResponseTimeNs and currentState. This should be
153 | * called periodically, such as every time a heartbeat is sent, and after it receives an offsetNs
154 | * update RPC.
155 | */
156 | private void updateState() {
157 | final long timestamp = localClock.read();
158 | final long timeSinceLastLeaderResponseNs = timestamp - lastLeaderResponseTimeNs;
159 | final long timeSinceLastLeaderOffsetResponseNs = timestamp - lastLeaderOffsetResponseTimeNs;
160 | updateState(timeSinceLastLeaderResponseNs, timeSinceLastLeaderOffsetResponseNs);
161 | }
162 |
163 | /**
164 | * SoftwareSyncClient only has two states: WAITING_FOR_LEADER (false) and SYNCED (true).
165 | *
166 | *
The state is determined based on the time since the last leader heartbeat acknowledge and
167 | * the time since the last offsetNs was given by the leader.
168 | *
169 | *
If the time since the leader has responded is longer than STALE_TIME_NS or the last offsetNs
170 | * received happened longer than STALE_OFFSET_TIME_NS ago, then transition to the not synced
171 | * WAITING_FOR_LEADER state. Otherwise, transition to the SYNCED state.
172 | */
173 | private void updateState(
174 | final long timeSinceLastLeaderResponseNs, final long timeSinceLastLeaderOffsetResponseNs) {
175 | final boolean newSyncState =
176 | (lastLeaderResponseTimeNs != 0
177 | && lastLeaderOffsetResponseTimeNs != 0
178 | && timeSinceLastLeaderResponseNs < SyncConstants.STALE_TIME_NS
179 | && timeSinceLastLeaderOffsetResponseNs < SyncConstants.STALE_OFFSET_TIME_NS);
180 | synchronized (syncLock) {
181 | if (newSyncState == synced) {
182 | return; // No state change, do nothing.
183 | }
184 |
185 | // Update synchronization state.
186 | synced = newSyncState;
187 |
188 | if (synced) { // WAITING_FOR_LEADER -> SYNCED.
189 | onRpc(SyncConstants.METHOD_MSG_SYNCING, null);
190 | } else { // SYNCED -> WAITING_FOR_LEADER.
191 | onRpc(SyncConstants.METHOD_MSG_WAITING_FOR_LEADER, null);
192 | }
193 | }
194 | }
195 |
196 | /** Start SNTP thread if it's not already running. */
197 | private void maybeStartSntpThread() {
198 | if (sntpThread == null || !sntpThread.isAlive()) {
199 | // Set up SNTP thread.
200 | sntpThread = new SntpListener(localClock, sntpSocket, sntpPort);
201 | sntpThread.start();
202 | }
203 | }
204 |
205 | /** Blocking stop of SNTP Thread if it's not already stopped. */
206 | private void maybeStopSntpThread() {
207 | if (sntpThread != null && sntpThread.isAlive()) {
208 | sntpThread.stopRunning();
209 | // Wait for thread to finish.
210 | try {
211 | sntpThread.join();
212 | } catch (InterruptedException e) {
213 | throw new IllegalStateException("SNTP Thread didn't close gracefully: " + e);
214 | }
215 | }
216 | }
217 |
218 | @Override
219 | public void close() throws IOException {
220 | maybeStopSntpThread();
221 | // Stop the heartbeat scheduler.
222 | heartbeatScheduler.shutdown();
223 | try {
224 | heartbeatScheduler.awaitTermination(1, TimeUnit.SECONDS);
225 | } catch (InterruptedException e) {
226 | Thread.currentThread().interrupt(); // Restore the interrupted status.
227 | // Should only happen on app shutdown, fall out and continue.
228 | }
229 |
230 | super.close();
231 | }
232 | }
233 |
--------------------------------------------------------------------------------
/app/src/main/java/com/googleresearch/capturesync/softwaresync/SoftwareSyncBase.java:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright 2019 The Google Research Authors.
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package com.googleresearch.capturesync.softwaresync;
18 |
19 | import android.os.HandlerThread;
20 | import android.util.Log;
21 | import java.io.Closeable;
22 | import java.io.IOException;
23 | import java.net.BindException;
24 | import java.net.DatagramPacket;
25 | import java.net.DatagramSocket;
26 | import java.net.InetAddress;
27 | import java.net.InetSocketAddress;
28 | import java.net.SocketException;
29 | import java.net.SocketTimeoutException;
30 | import java.nio.ByteBuffer;
31 | import java.util.HashMap;
32 | import java.util.Map;
33 | import java.util.concurrent.ExecutorService;
34 | import java.util.concurrent.Executors;
35 |
36 | /**
37 | * SoftwareSyncBase is the abstract base class to SoftwareSyncLeader and SoftwareSyncClient, holding
38 | * shared objects such as UDP ports and sockets, local client information and methods for starting
39 | * and stopping shared threads such as the rpc socket thread.
40 | *
41 | *
When the user is finished they should call the idempotent method close().
42 | */
43 | public abstract class SoftwareSyncBase implements Closeable, TimeDomainConverter {
44 | static final String TAG = "SoftwareSyncBase";
45 | private final ClientInfo localClientInfo; // Client info for this device.
46 | private final InetAddress leaderAddress;
47 | final Ticker localClock;
48 |
49 | /**
50 | * Offset to convert local time to leader time. leader_time = local_elapsed_time -
51 | * leader_from_local.
52 | */
53 | private long leaderFromLocalNs = 0;
54 |
55 | /* SNTP Setup */
56 | final int sntpPort;
57 | final DatagramSocket sntpSocket;
58 |
59 | /* RPC Setup. */
60 | private final int rpcPort;
61 | private final DatagramSocket rpcSocket;
62 | private final RpcThread rpcListenerThread;
63 | final Map rpcMap = new HashMap<>();
64 | /** Handle onRPC events on a separate thread. */
65 | private final ExecutorService rpcExecutor = Executors.newSingleThreadExecutor();
66 |
67 | SoftwareSyncBase(String name, Ticker localClock, InetAddress address, InetAddress leaderAddress) {
68 | this.rpcPort = SyncConstants.RPC_PORT;
69 | this.sntpPort = SyncConstants.SNTP_PORT;
70 | this.localClock = localClock;
71 |
72 | // Set up local ClientInfo from the provided address.
73 | localClientInfo = ClientInfo.create(name, address);
74 |
75 | // Leader device ip address is provided by the user.
76 | this.leaderAddress = leaderAddress;
77 |
78 | // Open sockets and start communication threads between leader and client devices.
79 | try {
80 | rpcSocket = new DatagramSocket(null);
81 | rpcSocket.setReuseAddress(true);
82 | rpcSocket.setSoTimeout(SyncConstants.SOCKET_WAIT_TIME_MS);
83 | rpcSocket.bind(new InetSocketAddress(SyncConstants.RPC_PORT));
84 |
85 | sntpSocket = new DatagramSocket(null);
86 | sntpSocket.setReuseAddress(true);
87 | sntpSocket.setSoTimeout(SyncConstants.SOCKET_WAIT_TIME_MS);
88 | sntpSocket.bind(new InetSocketAddress(SyncConstants.SNTP_PORT));
89 |
90 | } catch (BindException e) {
91 | throw new IllegalArgumentException("Socket already in use, close app and restart: " + e);
92 | } catch (SocketException e) {
93 | throw new IllegalArgumentException("Unable to open Sockets: " + e);
94 | }
95 |
96 | // Start an RPC thread loop that listens for packets on the rpc socket, processes and calls
97 | // onRpc with the processed method and payload.
98 | rpcListenerThread = new RpcThread();
99 | rpcListenerThread.start();
100 | }
101 |
102 | /**
103 | * Returns leader synchronized time in nanoseconds. This is in the clock domain of the leader's
104 | * localClock (SystemClock.elapsedRealtimeNanos())
105 | */
106 | public long getLeaderTimeNs() {
107 | return leaderTimeForLocalTimeNs(localClock.read());
108 | }
109 |
110 | /**
111 | * Calculates the leader time associated with the given local time in nanoseconds. The local time
112 | * must be in the SystemClock.elapsedRealTimeNanos() localClock domain, nanosecond units. This
113 | * includes timestamps such as the sensor timestamp from the camera. leader_time =
114 | * local_elapsed_time_ns + leader_from_local_ns.
115 | *
116 | * @param localTimeNs given local time (local clock SystemClock.elapsedRealtimeNanos() domain).
117 | * @return leader synchronized time in nanoseconds.
118 | */
119 | @Override
120 | public long leaderTimeForLocalTimeNs(long localTimeNs) {
121 | return localTimeNs - leaderFromLocalNs;
122 | }
123 |
124 | public String getName() {
125 | return localClientInfo.name();
126 | }
127 |
128 | ClientInfo getLocalClientInfo() {
129 | return localClientInfo;
130 | }
131 |
132 | public InetAddress getLeaderAddress() {
133 | return leaderAddress;
134 | }
135 |
136 | /**
137 | * Returns get the localClock offsetNs between this devices local elapsed time and the leader in
138 | * nanoseconds.
139 | */
140 | public long getLeaderFromLocalNs() {
141 | return leaderFromLocalNs;
142 | }
143 |
144 | /** Set the offsetNs between this device's local elapsed time and the leader synchronized time. */
145 | void setLeaderFromLocalNs(long value) {
146 | leaderFromLocalNs = value;
147 | }
148 |
149 | void addPublicRpcCallbacks(Map callbacks) {
150 | for (Integer key : callbacks.keySet()) {
151 | if (key < SyncConstants.START_NON_SOFTWARESYNC_METHOD_IDS) {
152 | throw new IllegalArgumentException(
153 | String.format(
154 | "Given method id %s, User method ids must" + " be >= %s",
155 | key, SyncConstants.START_NON_SOFTWARESYNC_METHOD_IDS));
156 | }
157 | }
158 | rpcMap.putAll(callbacks);
159 | }
160 |
161 | /** Sends a message with arguments to the specified address over the rpc socket. */
162 | void sendRpc(int method, String arguments, InetAddress address) {
163 | byte[] messagePayload = arguments.getBytes();
164 | if (messagePayload.length + 4 > SyncConstants.RPC_BUFFER_SIZE) {
165 | throw new IllegalArgumentException(
166 | String.format(
167 | "RPC arguments too big %d v %d",
168 | messagePayload.length + 4, SyncConstants.RPC_BUFFER_SIZE));
169 | }
170 |
171 | byte[] fullPayload =
172 | ByteBuffer.allocate(messagePayload.length + 4).putInt(method).put(messagePayload).array();
173 |
174 | DatagramPacket packet = new DatagramPacket(fullPayload, fullPayload.length, address, rpcPort);
175 | try {
176 | rpcSocket.send(packet);
177 | } catch (IOException e) {
178 | throw new IllegalStateException("Error sending RPC packet.");
179 | }
180 | }
181 |
182 | /**
183 | * RPC thread loop that listens for packets on the rpc socket, processes and calls onRpc with the
184 | * processed method and payload.
185 | */
186 | private class RpcThread extends HandlerThread {
187 | private boolean running;
188 |
189 | RpcThread() {
190 | super("RpcListenerThread");
191 | }
192 |
193 | void stopRunning() {
194 | running = false;
195 | }
196 |
197 | @Override
198 | @SuppressWarnings("FutureReturnValueIgnored")
199 | public void run() {
200 | running = true;
201 |
202 | byte[] buf = new byte[SyncConstants.RPC_BUFFER_SIZE];
203 | while (running && !rpcSocket.isClosed()) {
204 | DatagramPacket packet = new DatagramPacket(buf, buf.length);
205 |
206 | try {
207 | // Wait for a client message.
208 | rpcSocket.receive(packet);
209 |
210 | // Separate packet string into int method and string payload
211 | // First 4 bytes is the integer method.
212 | ByteBuffer packetByteBuffer = ByteBuffer.wrap(packet.getData());
213 | int method = packetByteBuffer.getInt(); // From first 4 bytes.
214 | // Rest of the bytes are the payload.
215 | String payload = new String(packet.getData(), 4, packet.getLength() - 4);
216 |
217 | // Call onRpc with the method and payload in a separate thread.
218 | rpcExecutor.submit(() -> onRpc(method, payload));
219 |
220 | } catch (SocketTimeoutException e) {
221 | // Do nothing since this is a normal timeout of the receive.
222 | } catch (IOException e) {
223 | if (running || rpcSocket.isClosed()) {
224 | Log.w(TAG, "Shutdown arrived in middle of a socket receive, ignoring error.");
225 | } else {
226 | throw new IllegalStateException("Socket Receive/Send error: " + e);
227 | }
228 | }
229 | }
230 | }
231 | }
232 |
233 | /** Handle RPCs using the existing RPC map. */
234 | public void onRpc(int method, String payload) {
235 | RpcCallback callback = rpcMap.get(method);
236 | if (callback != null) {
237 | callback.call(payload);
238 | }
239 | }
240 |
241 | /**
242 | * Idempotent close that handles closing sockets, threads if they are open or running, etc. If a
243 | * user overrides this method it is expected make sure to call super as well.
244 | */
245 | @Override
246 | public void close() throws IOException {
247 | rpcListenerThread.stopRunning();
248 | rpcSocket.close();
249 | sntpSocket.close();
250 | }
251 | }
252 |
--------------------------------------------------------------------------------
/app/src/main/java/com/googleresearch/capturesync/softwaresync/SimpleNetworkTimeProtocol.java:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright 2019 The Google Research Authors.
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package com.googleresearch.capturesync.softwaresync;
18 |
19 | import android.util.Log;
20 | import java.io.IOException;
21 | import java.net.DatagramPacket;
22 | import java.net.DatagramSocket;
23 | import java.net.InetAddress;
24 | import java.net.SocketTimeoutException;
25 | import java.nio.ByteBuffer;
26 | import java.nio.LongBuffer;
27 | import java.util.HashSet;
28 | import java.util.Set;
29 | import java.util.concurrent.ExecutorService;
30 | import java.util.concurrent.Executors;
31 | import java.util.concurrent.TimeUnit;
32 |
33 | /**
34 | * Simple Network Time Protocol (SNTP) for clock synchronization logic between leader and clients.
35 | * This implements the leader half of the protocol, with SntpListener implementing the client side.
36 | *
37 | *
Provides a doSNTP function allowing the leader to initiate synchronization with a client
38 | * address. The SntpListener class is used by the clients to handle responding to these messages.
39 | */
40 | public class SimpleNetworkTimeProtocol implements AutoCloseable {
41 | private static final String TAG = "SimpleNetworkTimeProtocol";
42 |
43 | private final DatagramSocket nptpSocket;
44 | private final int nptpPort;
45 |
46 | /** Sequentially manages SNTP synchronization of clients. */
47 | private final ExecutorService nptpExecutor = Executors.newSingleThreadExecutor();
48 |
49 | /** Keeps track of SNTP client sync tasks already in the pipeline to avoid duplicate requests. */
50 | private final Set clientSyncTasks = new HashSet<>();
51 |
52 | private final Object clientSyncTasksLock = new Object();
53 | private final SoftwareSyncLeader leader;
54 | private final Ticker localClock;
55 |
56 | public SimpleNetworkTimeProtocol(
57 | Ticker localClock, DatagramSocket nptpSocket, int nptpPort, SoftwareSyncLeader leader) {
58 | this.localClock = localClock;
59 | this.nptpSocket = nptpSocket;
60 | this.nptpPort = nptpPort;
61 | this.leader = leader;
62 | }
63 |
64 | /**
65 | * Check if requesting client is already in the queue. If not, then submit a new task to do n-PTP
66 | * synchronization with that client. Synchronization involves sending and receiving messages on
67 | * the nptp socket, calculating the clock offsetNs, and finally sending an rpc to update the
68 | * offsetNs on the client.
69 | */
70 | @SuppressWarnings("FutureReturnValueIgnored")
71 | void submitNewSyncRequest(final InetAddress clientAddress) {
72 | // Skip if we have already enqueued a sync task with this client.
73 | synchronized (clientSyncTasksLock) {
74 | if (clientSyncTasks.contains(clientAddress)) {
75 | Log.w(TAG, "Already queued sync with " + clientAddress + ", skipping.");
76 | return;
77 | } else {
78 | clientSyncTasks.add(clientAddress);
79 | }
80 | }
81 |
82 | // Add SNTP request to executor queue.
83 | nptpExecutor.submit(
84 | () -> {
85 | // If the client no longer exists, no need to synchronize.
86 | if (!leader.getClients().containsKey(clientAddress)) {
87 | Log.w(TAG, "Client was removed, exiting SNTP routine.");
88 | return true;
89 | }
90 |
91 | Log.d(TAG, "Starting sync with client" + clientAddress);
92 | // Calculate clock offsetNs between client and leader using a naive
93 | // version of the precision time protocol (SNTP).
94 | SntpOffsetResponse response = doSNTP(clientAddress);
95 |
96 | if (response.status()) {
97 | // Apply local offsetNs to bestOffset so everyone has the same offsetNs.
98 | final long alignedOffset = response.offsetNs() + leader.getLeaderFromLocalNs();
99 |
100 | // Update client sync accuracy locally.
101 | leader.updateClientWithOffsetResponse(clientAddress, response);
102 |
103 | // Send an RPC to update the offsetNs on the client.
104 | Log.d(TAG, "Sending offsetNs update to " + clientAddress + ": " + alignedOffset);
105 | leader.sendRpc(
106 | SyncConstants.METHOD_OFFSET_UPDATE, String.valueOf(alignedOffset), clientAddress);
107 | }
108 |
109 | // Pop client from the queue regardless of success state. Clients will be added back in
110 | // the queue as needed based on their state at the next heartbeat.
111 | synchronized (clientSyncTasksLock) {
112 | clientSyncTasks.remove(clientAddress);
113 | }
114 |
115 | if (response.status()) {
116 | leader.onRpc(SyncConstants.METHOD_MSG_OFFSET_UPDATED, clientAddress.toString());
117 | }
118 |
119 | return response.status();
120 | });
121 | }
122 |
123 | /**
124 | * Performs Min filter SNTP synchronization with the client over the socket using UDP.
125 | *
126 | *
Naive PTP protocol is as follows:
127 | *
128 | *
[1]At time t0 in the leader clock domain, Leader sends the message (t0).
129 | *
130 | *
[2]At time t1 in the client clock domain, Client receives the message (t0).
131 | *
132 | *
[3]At time t2 in the client clock domain, Client sends the message (t0,t1,t2).
133 | *
134 | *
[4]At time t3 in the leader clock domain, Leader receives the message (t0,t1,t2).
135 | *
136 | *
Final Clock offsetNs is calculated using the message with the smallest round-trip latency.
142 | *
143 | * @param clientAddress The client InetAddress to perform synchronization with.
144 | * @return SntpOffsetResponse containing the offsetNs and sync accuracy with the client.
145 | */
146 | private SntpOffsetResponse doSNTP(InetAddress clientAddress) throws IOException {
147 | final int longSize = Long.SIZE / Byte.SIZE;
148 | byte[] buf = new byte[longSize * 3];
149 | long bestLatency = Long.MAX_VALUE; // Start with initial high round trip
150 | long bestOffset = 0;
151 | // If there are several failed SNTP round trip sync messages, fail out.
152 | int missingMessageCountdown = 10;
153 | SntpOffsetResponse failureResponse =
154 | SntpOffsetResponse.create(/*offset=*/ 0, /*syncAccuracy=*/ 0, false);
155 |
156 | for (int i = 0; i < SyncConstants.NUM_SNTP_CYCLES; i++) {
157 | // 1 - Send UDP SNTP message to the client with t0 at time t0.
158 | long t0 = localClock.read();
159 | ByteBuffer t0bytebuffer = ByteBuffer.allocate(longSize);
160 | t0bytebuffer.putLong(t0);
161 | nptpSocket.send(new DatagramPacket(t0bytebuffer.array(), longSize, clientAddress, nptpPort));
162 |
163 | // Steps 2 and 3 happen on client side B.
164 | // 4 - Recv UDP message with t0,t0',t1 at time t1'.
165 | DatagramPacket packet = new DatagramPacket(buf, buf.length);
166 | try {
167 | nptpSocket.receive(packet);
168 | } catch (SocketTimeoutException e) {
169 | // If we didn't receive a message in time, then skip this PTP pair and continue.
170 | Log.w(TAG, "UDP PTP message missing, skipping");
171 | missingMessageCountdown--;
172 | if (missingMessageCountdown <= 0) {
173 | Log.w(
174 | TAG, String.format("Missed too many messages, leaving doSNTP for %s", clientAddress));
175 | return failureResponse;
176 | }
177 | continue;
178 | }
179 | final long t3 = localClock.read();
180 |
181 | if (packet.getLength() != 3 * longSize) {
182 | Log.w(TAG, "Corrupted UDP message, skipping");
183 | continue;
184 | }
185 | ByteBuffer t3buffer = ByteBuffer.allocate(longSize * 3);
186 | t3buffer.put(packet.getData(), 0, packet.getLength());
187 | t3buffer.flip();
188 | LongBuffer longbuffer = t3buffer.asLongBuffer();
189 | final long t0Msg = longbuffer.get();
190 | final long t1Msg = longbuffer.get();
191 | final long t2Msg = longbuffer.get();
192 |
193 | // Confirm that the received message contains the same t0 as the t0 from this cycle,
194 | // otherwise skip.
195 | if (t0Msg != t0) {
196 | Log.w(
197 | TAG,
198 | String.format(
199 | "Out of order PTP message received, skipping: Expected %d vs %d", t0, t0Msg));
200 |
201 | // Note: Wait or catch and throw away the next message to get back in sync.
202 | try {
203 | nptpSocket.receive(packet);
204 | } catch (SocketTimeoutException e) {
205 | // If still waiting, continue.
206 | }
207 | // Since this was an incorrect cycle, move on to a new cycle.
208 | continue;
209 | }
210 |
211 | final long timeOffset = ((t1Msg - t0) + (t2Msg - t3)) / 2;
212 | final long roundTripLatency = (t3 - t0) - (t2Msg - t1Msg);
213 |
214 | Log.v(
215 | TAG,
216 | String.format(
217 | "% 3d | PTP: %d,%d,%d,%d | Latency: %,.3f ms",
218 | i, t0, t1Msg, t2Msg, t3, TimeUtils.nanosToMillis((double) roundTripLatency)));
219 |
220 | if (roundTripLatency < bestLatency) {
221 | bestOffset = timeOffset;
222 | bestLatency = roundTripLatency;
223 | // If round trip latency is under minimum round trip latency desired, stop here.
224 | if (roundTripLatency < SyncConstants.MIN_ROUND_TRIP_LATENCY_NS) {
225 | break;
226 | }
227 | }
228 | }
229 |
230 | Log.v(
231 | TAG,
232 | String.format(
233 | "Client %s : SNTP best latency %,d ns, offsetNs %,d ns",
234 | clientAddress, bestLatency, bestOffset));
235 |
236 | return SntpOffsetResponse.create(bestOffset, bestLatency, true);
237 | }
238 |
239 | @Override
240 | public void close() {
241 | nptpExecutor.shutdown();
242 | // Wait up to 0.5 seconds for the executor service to finish.
243 | try {
244 | nptpExecutor.awaitTermination(500, TimeUnit.MILLISECONDS);
245 | } catch (InterruptedException e) {
246 | throw new IllegalStateException("SNTP Executor didn't close gracefully: " + e);
247 | }
248 | }
249 | }
250 |
--------------------------------------------------------------------------------
/app/src/main/java/com/googleresearch/capturesync/SoftwareSyncController.java:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright 2019 The Google Research Authors.
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package com.googleresearch.capturesync;
18 |
19 | import android.content.Context;
20 | import android.graphics.Color;
21 | import android.net.wifi.WifiManager;
22 | import android.provider.Settings.Secure;
23 | import android.util.Log;
24 | import android.widget.TextView;
25 | import com.googleresearch.capturesync.softwaresync.ClientInfo;
26 | import com.googleresearch.capturesync.softwaresync.NetworkHelpers;
27 | import com.googleresearch.capturesync.softwaresync.RpcCallback;
28 | import com.googleresearch.capturesync.softwaresync.SoftwareSyncBase;
29 | import com.googleresearch.capturesync.softwaresync.SoftwareSyncClient;
30 | import com.googleresearch.capturesync.softwaresync.SoftwareSyncLeader;
31 | import com.googleresearch.capturesync.softwaresync.SyncConstants;
32 | import com.googleresearch.capturesync.softwaresync.TimeUtils;
33 | import java.io.Closeable;
34 | import java.io.IOException;
35 | import java.net.InetAddress;
36 | import java.net.SocketException;
37 | import java.net.UnknownHostException;
38 | import java.util.HashMap;
39 | import java.util.Map;
40 | import java.util.Map.Entry;
41 |
42 | // Note : Needs Network permissions.
43 |
44 | /** Controller managing setup and tear down the SoftwareSync object. */
45 | public class SoftwareSyncController implements Closeable {
46 |
47 | private static final String TAG = "SoftwareSyncController";
48 | private final MainActivity context;
49 | private final TextView statusView;
50 | private final PhaseAlignController phaseAlignController;
51 | private boolean isLeader;
52 | SoftwareSyncBase softwareSync;
53 |
54 | /* Tell devices to save the frame at the requested trigger time. */
55 | public static final int METHOD_SET_TRIGGER_TIME = 200_000;
56 | /* Tell devices to phase align. */
57 | public static final int METHOD_DO_PHASE_ALIGN = 200_001;
58 | /* Tell devices to set manual exposure and white balance to the requested values. */
59 | public static final int METHOD_SET_2A = 200_002;
60 |
61 | private long upcomingTriggerTimeNs;
62 |
63 | /**
64 | * Constructor passed in with: - context - For setting UI elements and triggering captures. -
65 | * captureButton - The button used to send at trigger request by the leader. - statusView - The
66 | * TextView used to show currently connected clients on the leader device.
67 | */
68 | public SoftwareSyncController(
69 | MainActivity context, PhaseAlignController phaseAlignController, TextView statusView) {
70 | this.context = context;
71 | this.phaseAlignController = phaseAlignController;
72 | this.statusView = statusView;
73 |
74 | setupSoftwareSync();
75 | }
76 |
77 | @SuppressWarnings("StringSplitter")
78 | private void setupSoftwareSync() {
79 | Log.w(TAG, "setup SoftwareSync");
80 | if (softwareSync != null) {
81 | return;
82 | }
83 |
84 | // Get Wifi Manager and use NetworkHelpers to determine local and leader IP addresses.
85 | WifiManager wifiManager = (WifiManager) context.getSystemService(Context.WIFI_SERVICE);
86 | InetAddress leaderAddress;
87 | InetAddress localAddress;
88 |
89 | // Use last 4 digits of the serial as the name of the client.
90 | String name = lastFourSerial();
91 | Log.w(TAG, "Name/Serial# (Last 4 digits): " + name);
92 |
93 | try {
94 | NetworkHelpers networkHelper = new NetworkHelpers(wifiManager);
95 | localAddress = NetworkHelpers.getIPAddress();
96 | leaderAddress = networkHelper.getHotspotServerAddress();
97 |
98 | // Note: This is a brittle way of checking leadership that may not work on all devices.
99 | // Leader only if it is the one with same IP address as the server, or a zero IP address.
100 | if (localAddress.equals(leaderAddress)) {
101 | Log.d(TAG, "Leader == Local Address");
102 | isLeader = true;
103 | } else if (localAddress.equals(InetAddress.getByName("0.0.0.0"))) {
104 | Log.d(TAG, "Leader == 0.0.0.0");
105 | isLeader = true;
106 | }
107 |
108 | Log.w(
109 | TAG,
110 | String.format(
111 | "Current IP: %s , Leader IP: %s | Leader? %s",
112 | localAddress, leaderAddress, isLeader ? "Y" : "N"));
113 | } catch (UnknownHostException | SocketException e) {
114 | if (isLeader) {
115 | Log.e(TAG, "Error: " + e);
116 | throw new IllegalStateException(
117 | "Unable to get IP addresses, check if WiFi hotspot is enabled.", e);
118 | } else {
119 | throw new IllegalStateException(
120 | "Unable to get IP addresses, check Network permissions.", e);
121 | }
122 | }
123 |
124 | // Set up shared rpcs.
125 | Map sharedRpcs = new HashMap<>();
126 | sharedRpcs.put(
127 | METHOD_SET_TRIGGER_TIME,
128 | payload -> {
129 | Log.v(TAG, "Setting next trigger to" + payload);
130 | upcomingTriggerTimeNs = Long.valueOf(payload);
131 | context.setUpcomingCaptureStill(upcomingTriggerTimeNs);
132 | });
133 |
134 | sharedRpcs.put(
135 | METHOD_DO_PHASE_ALIGN,
136 | payload -> {
137 | // Note: One could pass the current phase of the leader and have all clients sync to
138 | // that, reducing potential error, though special attention should be placed to phases
139 | // close to the zero or period boundary.
140 | Log.v(TAG, "Starting phase alignment.");
141 | phaseAlignController.startAlign();
142 | });
143 |
144 | sharedRpcs.put(
145 | METHOD_SET_2A,
146 | payload -> {
147 | Log.v(TAG, "Received payload: " + payload);
148 |
149 | String[] segments = payload.split(",");
150 | if (segments.length != 2) {
151 | throw new IllegalArgumentException("Wrong number of segments in payload: " + payload);
152 | }
153 | long sensorExposureNs = Long.parseLong(segments[0]);
154 | int sensorSensitivity = Integer.parseInt(segments[1]);
155 | context.set2aAndUpdatePreview(sensorExposureNs, sensorSensitivity);
156 | });
157 |
158 | if (isLeader) {
159 | // Leader.
160 | long initTimeNs = TimeUtils.millisToNanos(System.currentTimeMillis());
161 | // Create rpc mapping specific to leader.
162 | Map leaderRpcs = new HashMap<>(sharedRpcs);
163 | leaderRpcs.put(SyncConstants.METHOD_MSG_ADDED_CLIENT, payload -> updateClientsUI());
164 | leaderRpcs.put(SyncConstants.METHOD_MSG_REMOVED_CLIENT, payload -> updateClientsUI());
165 | leaderRpcs.put(SyncConstants.METHOD_MSG_SYNCING, payload -> updateClientsUI());
166 | leaderRpcs.put(SyncConstants.METHOD_MSG_OFFSET_UPDATED, payload -> updateClientsUI());
167 | softwareSync = new SoftwareSyncLeader(name, initTimeNs, localAddress, leaderRpcs);
168 | } else {
169 | // Client.
170 | Map clientRpcs = new HashMap<>(sharedRpcs);
171 | clientRpcs.put(
172 | SyncConstants.METHOD_MSG_WAITING_FOR_LEADER,
173 | payload ->
174 | context.runOnUiThread(
175 | () -> statusView.setText(softwareSync.getName() + ": Waiting for Leader")));
176 | clientRpcs.put(
177 | SyncConstants.METHOD_MSG_SYNCING,
178 | payload ->
179 | context.runOnUiThread(
180 | () -> statusView.setText(softwareSync.getName() + ": Waiting for Sync")));
181 | clientRpcs.put(
182 | SyncConstants.METHOD_MSG_OFFSET_UPDATED,
183 | payload ->
184 | context.runOnUiThread(
185 | () ->
186 | statusView.setText(
187 | String.format(
188 | "Client %s\n-Synced to Leader %s",
189 | softwareSync.getName(), softwareSync.getLeaderAddress()))));
190 | softwareSync = new SoftwareSyncClient(name, localAddress, leaderAddress, clientRpcs);
191 | }
192 |
193 | if (isLeader) {
194 | context.runOnUiThread(
195 | () -> {
196 | statusView.setText("Leader : " + softwareSync.getName());
197 | statusView.setTextColor(Color.rgb(0, 139, 0)); // Dark green.
198 | });
199 | } else {
200 | context.runOnUiThread(
201 | () -> {
202 | statusView.setText("Client : " + softwareSync.getName());
203 | statusView.setTextColor(Color.rgb(0, 0, 139)); // Dark blue.
204 | });
205 | }
206 | }
207 |
208 | /**
209 | * Show the number of connected clients on the leader status UI.
210 | *
211 | *
If the number of clients doesn't equal TOTAL_NUM_CLIENTS, show as bright red.
212 | */
213 | private void updateClientsUI() {
214 | SoftwareSyncLeader leader = ((SoftwareSyncLeader) softwareSync);
215 | final int clientCount = leader.getClients().size();
216 | context.runOnUiThread(
217 | () -> {
218 | StringBuilder msg = new StringBuilder();
219 | msg.append(
220 | String.format("Leader %s: %d clients.\n", softwareSync.getName(), clientCount));
221 | for (Entry entry : leader.getClients().entrySet()) {
222 | ClientInfo client = entry.getValue();
223 | if (client.syncAccuracy() == 0) {
224 | msg.append(String.format("-Client %s: syncing...\n", client.name()));
225 | } else {
226 | msg.append(
227 | String.format(
228 | "-Client %s: %.2f ms sync\n", client.name(), client.syncAccuracy() / 1e6));
229 | }
230 | }
231 | statusView.setText(msg.toString());
232 | });
233 | }
234 |
235 | @Override
236 | public void close() {
237 | Log.w(TAG, "close SoftwareSyncController");
238 | if (softwareSync != null) {
239 | try {
240 | softwareSync.close();
241 | } catch (IOException e) {
242 | throw new IllegalStateException("Error closing SoftwareSync", e);
243 | }
244 | softwareSync = null;
245 | }
246 | }
247 |
248 | private String lastFourSerial() {
249 | String serial = Secure.getString(context.getContentResolver(), Secure.ANDROID_ID);
250 | if (serial.length() <= 4) {
251 | return serial;
252 | } else {
253 | return serial.substring(serial.length() - 4);
254 | }
255 | }
256 |
257 | public boolean isLeader() {
258 | return isLeader;
259 | }
260 | }
261 |
--------------------------------------------------------------------------------
/app/src/main/java/com/googleresearch/capturesync/softwaresync/SoftwareSyncLeader.java:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright 2019 The Google Research Authors.
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package com.googleresearch.capturesync.softwaresync;
18 |
19 | import android.util.Log;
20 | import java.io.IOException;
21 | import java.net.InetAddress;
22 | import java.net.UnknownHostException;
23 | import java.util.Arrays;
24 | import java.util.Collections;
25 | import java.util.HashMap;
26 | import java.util.Iterator;
27 | import java.util.List;
28 | import java.util.Map;
29 | import java.util.Map.Entry;
30 | import java.util.concurrent.ExecutorService;
31 | import java.util.concurrent.Executors;
32 | import java.util.concurrent.ScheduledExecutorService;
33 | import java.util.concurrent.TimeUnit;
34 |
35 | /**
36 | * Leader which listens for registrations from SoftwareSyncClients, allowing it to broadcast times
37 | * at which both itself and clients will simultaneously perform actions.
38 | *
39 | *
SoftwareSync assumes that clients are connected to a leader wifi hotspot created by the device
40 | * this instance is running on.
41 | *
42 | *
The leader listens for client registrations and keeps track of connected clients. It also
43 | * listens for SNTP synchronization requests and processes them in a queue. Once it has determined
44 | * the offsetNs it sends an rpc message to the client, thereby synchronizing the client's clock with
45 | * the leader's to the precision requested.
46 | */
47 | public class SoftwareSyncLeader extends SoftwareSyncBase {
48 | /** List of connected clients. */
49 | private final Map clients = new HashMap<>();
50 |
51 | private final Object clientsLock = new Object();
52 |
53 | /** Keeps track of how long since each client heartbeat was received, removing when stale. */
54 | private final ScheduledExecutorService staleClientChecker = Executors.newScheduledThreadPool(1);
55 |
56 | /** Send RPC messages on a separate thread, avoiding Network on Main Thread exceptions. */
57 | private final ExecutorService rpcMessageExecutor = Executors.newSingleThreadExecutor();
58 |
59 | /** Manages SNTP synchronization of clients. */
60 | private final SimpleNetworkTimeProtocol sntp;
61 |
62 | public SoftwareSyncLeader(
63 | String name, long initialTime, InetAddress address, Map rpcCallbacks) {
64 | this(name, new SystemTicker(), initialTime, address, rpcCallbacks);
65 | }
66 |
67 | @SuppressWarnings("FutureReturnValueIgnored")
68 | private SoftwareSyncLeader(
69 | String name,
70 | Ticker localClock,
71 | long initialTime,
72 | InetAddress address,
73 | Map rpcCallbacks) {
74 | // Note: Leader address is required to be the same as local address.
75 | super(name, localClock, address, address);
76 |
77 | // Set up the offsetNs so that the leader synchronized time (via getLeaderTimeNs()) on all
78 | // devices
79 | // runs starting from the initial time given. When initialTimeNs is zero the
80 | // leader synchronized time is the default of localClock, ie. the time since boot of the leader
81 | // device.
82 | // For convenience, all devices could instead be shifted to the leader device UTC time,
83 | // ex. initialTimeNs = TimeUtils.millisToNanos(System.currentTimeMillis())
84 | setLeaderFromLocalNs(localClock.read() - initialTime);
85 |
86 | // Add client-specific RPC callbacks.
87 | rpcMap.put(
88 | SyncConstants.METHOD_HEARTBEAT,
89 | payload -> {
90 | // Received heartbeat from client, send back an acknowledge and then
91 | // check the client state and add to sntp queue if needed.
92 | Log.v(TAG, "Heartbeat received from client: " + payload);
93 | try {
94 | processHeartbeatRpc(payload);
95 | } catch (UnknownHostException e) {
96 | Log.e(TAG, "Processed heartbeat with corrupt host address: " + payload);
97 | }
98 | });
99 |
100 | // Add callbacks passed by user.
101 | addPublicRpcCallbacks(rpcCallbacks);
102 |
103 | // Set up SNTP instance for synchronizing with clients.
104 | sntp = new SimpleNetworkTimeProtocol(localClock, sntpSocket, SyncConstants.SNTP_PORT, this);
105 |
106 | // Start periodically checking for stale clients and removing as needed.
107 | staleClientChecker.scheduleAtFixedRate(
108 | this::removeStaleClients, 0, SyncConstants.STALE_TIME_NS, TimeUnit.NANOSECONDS);
109 | }
110 |
111 | public Map getClients() {
112 | synchronized (clientsLock) {
113 | return Collections.unmodifiableMap(clients);
114 | }
115 | }
116 |
117 | /**
118 | * Checks if the address is already associated with one of the clients in the list of tracked
119 | * clients. If so, just update the last heartbeat, otherwise create a new client entry in the
120 | * list.
121 | */
122 | private void addOrUpdateClient(String name, InetAddress address) {
123 | // Check if it's a new client, so we don't add again.
124 | synchronized (clientsLock) {
125 | boolean clientExists = clients.containsKey(address);
126 | // Add or replace entry with an updated ClientInfo.
127 | long offsetNs = 0;
128 | long syncAccuracyNs = 0;
129 | if (clientExists) {
130 | offsetNs = clients.get(address).offset();
131 | syncAccuracyNs = clients.get(address).syncAccuracy();
132 | }
133 | ClientInfo updatedClient =
134 | ClientInfo.create(name, address, offsetNs, syncAccuracyNs, localClock.read());
135 | clients.put(address, updatedClient);
136 |
137 | if (!clientExists) {
138 | // Notify via message on interface if client is new.
139 | onRpc(SyncConstants.METHOD_MSG_ADDED_CLIENT, updatedClient.name());
140 | }
141 | }
142 | }
143 |
144 | /** Removes clients whose last heartbeat was longer than STALE_TIME_NS ago. */
145 | private void removeStaleClients() {
146 | long t = localClock.read();
147 | synchronized (clientsLock) {
148 | // Use iterator to avoid concurrent modification exception.
149 | Iterator> clientIterator = clients.entrySet().iterator();
150 | while (clientIterator.hasNext()) {
151 | ClientInfo client = clientIterator.next().getValue();
152 | long timeSince = t - client.lastHeartbeat();
153 | if (timeSince > SyncConstants.STALE_TIME_NS) {
154 | Log.w(
155 | TAG,
156 | String.format(
157 | "Stale client %s : time since %,d seconds",
158 | client.name(), TimeUtils.nanosToSeconds(timeSince)));
159 |
160 | // Remove entry from the client list first.
161 | clientIterator.remove();
162 | // Client hasn't responded in a while, remove from list.
163 | onRpc(SyncConstants.METHOD_MSG_REMOVED_CLIENT, client.name());
164 | }
165 | }
166 | }
167 | }
168 |
169 | /** Finds and updates client sync accuracy within list. */
170 | void updateClientWithOffsetResponse(InetAddress clientAddress, SntpOffsetResponse response) {
171 | // Update client sync accuracy locally.
172 | synchronized (clientsLock) {
173 | if (!clients.containsKey(clientAddress)) {
174 | Log.w(TAG, "Tried to update a client info that is no longer in the list, Skipping.");
175 | return;
176 | }
177 | final ClientInfo client = clients.get(clientAddress);
178 | ClientInfo updatedClient =
179 | ClientInfo.create(
180 | client.name(),
181 | client.address(),
182 | response.offsetNs(),
183 | response.syncAccuracyNs(),
184 | client.lastHeartbeat());
185 | clients.put(client.address(), updatedClient);
186 | }
187 | }
188 |
189 | /**
190 | * Sends an RPC to every client in the leader's clients list.
191 | *
192 | * @param method int type of RPC (in {@link SyncConstants}).
193 | * @param payload String payload.
194 | */
195 | @SuppressWarnings("FutureReturnValueIgnored")
196 | private void internalBroadcastRpc(int method, String payload) {
197 | // Send RPC message to all clients and call onRPC of self as well.
198 | synchronized (clientsLock) {
199 | for (InetAddress address : clients.keySet()) {
200 | rpcMessageExecutor.submit(() -> sendRpc(method, payload, address));
201 | }
202 | }
203 |
204 | // Also call onRpc for self (leader).
205 | onRpc(method, payload);
206 | }
207 |
208 | /**
209 | * Public-facing broadcast RPC to all current clients, for non-softwaresync RPC methods only.
210 | *
211 | * @param method int type of RPC, must be greater than {@link
212 | * SyncConstants#START_NON_SOFTWARESYNC_METHOD_IDS}.
213 | * @param payload String payload.
214 | */
215 | public void broadcastRpc(int method, String payload) {
216 | if (method < SyncConstants.START_NON_SOFTWARESYNC_METHOD_IDS) {
217 | throw new IllegalArgumentException(
218 | String.format(
219 | "Given method id %s, User method ids must" + " be >= %s",
220 | method, SyncConstants.START_NON_SOFTWARESYNC_METHOD_IDS));
221 | }
222 | internalBroadcastRpc(method, payload);
223 | }
224 |
225 | @Override
226 | public void close() throws IOException {
227 | sntp.close();
228 | staleClientChecker.shutdown();
229 | try {
230 | // Wait up to 0.5 seconds for this to close.
231 | staleClientChecker.awaitTermination(500, TimeUnit.MILLISECONDS);
232 | } catch (InterruptedException e) {
233 | Thread.currentThread().interrupt(); // Restore the interrupted status.
234 | // Should only happen on app shutdown, fall out and continue.
235 | }
236 | super.close();
237 | }
238 |
239 | /**
240 | * Process a heartbeat rpc call from a client by responding with a heartbeat acknowledge, adding
241 | * or updating the client in the tracked clients list, and submitting a new SNTP sync request if
242 | * the client state is not yet synchronized.
243 | *
244 | * @param payload format of "ClientName,ClientAddress,ClientState"
245 | */
246 | private void processHeartbeatRpc(String payload) throws UnknownHostException {
247 | List parts = Arrays.asList(payload.split(","));
248 | if (parts.size() != 3) {
249 | Log.e(
250 | TAG,
251 | "Heartbeat message has the wrong format, expected 3 comma-delimitted parts: "
252 | + payload
253 | + ". Skipping.");
254 | return;
255 | }
256 | String clientName = parts.get(0);
257 | InetAddress clientAddress = InetAddress.getByName(parts.get(1));
258 | boolean clientSyncState = Boolean.parseBoolean(parts.get(2));
259 |
260 | // Send heartbeat acknowledge RPC back to client first, containing the same payload.
261 | sendRpc(SyncConstants.METHOD_HEARTBEAT_ACK, payload, clientAddress);
262 |
263 | // Add or update client in clients.
264 | addOrUpdateClient(clientName, clientAddress);
265 |
266 | // If the client state is not yet synchronized, add it to the SNTP queue.
267 | if (!clientSyncState) {
268 | sntp.submitNewSyncRequest(clientAddress);
269 | }
270 | }
271 | }
272 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 |
2 | Apache License
3 | Version 2.0, January 2004
4 | http://www.apache.org/licenses/
5 |
6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
7 |
8 | 1. Definitions.
9 |
10 | "License" shall mean the terms and conditions for use, reproduction,
11 | and distribution as defined by Sections 1 through 9 of this document.
12 |
13 | "Licensor" shall mean the copyright owner or entity authorized by
14 | the copyright owner that is granting the License.
15 |
16 | "Legal Entity" shall mean the union of the acting entity and all
17 | other entities that control, are controlled by, or are under common
18 | control with that entity. For the purposes of this definition,
19 | "control" means (i) the power, direct or indirect, to cause the
20 | direction or management of such entity, whether by contract or
21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the
22 | outstanding shares, or (iii) beneficial ownership of such entity.
23 |
24 | "You" (or "Your") shall mean an individual or Legal Entity
25 | exercising permissions granted by this License.
26 |
27 | "Source" form shall mean the preferred form for making modifications,
28 | including but not limited to software source code, documentation
29 | source, and configuration files.
30 |
31 | "Object" form shall mean any form resulting from mechanical
32 | transformation or translation of a Source form, including but
33 | not limited to compiled object code, generated documentation,
34 | and conversions to other media types.
35 |
36 | "Work" shall mean the work of authorship, whether in Source or
37 | Object form, made available under the License, as indicated by a
38 | copyright notice that is included in or attached to the work
39 | (an example is provided in the Appendix below).
40 |
41 | "Derivative Works" shall mean any work, whether in Source or Object
42 | form, that is based on (or derived from) the Work and for which the
43 | editorial revisions, annotations, elaborations, or other modifications
44 | represent, as a whole, an original work of authorship. For the purposes
45 | of this License, Derivative Works shall not include works that remain
46 | separable from, or merely link (or bind by name) to the interfaces of,
47 | the Work and Derivative Works thereof.
48 |
49 | "Contribution" shall mean any work of authorship, including
50 | the original version of the Work and any modifications or additions
51 | to that Work or Derivative Works thereof, that is intentionally
52 | submitted to Licensor for inclusion in the Work by the copyright owner
53 | or by an individual or Legal Entity authorized to submit on behalf of
54 | the copyright owner. For the purposes of this definition, "submitted"
55 | means any form of electronic, verbal, or written communication sent
56 | to the Licensor or its representatives, including but not limited to
57 | communication on electronic mailing lists, source code control systems,
58 | and issue tracking systems that are managed by, or on behalf of, the
59 | Licensor for the purpose of discussing and improving the Work, but
60 | excluding communication that is conspicuously marked or otherwise
61 | designated in writing by the copyright owner as "Not a Contribution."
62 |
63 | "Contributor" shall mean Licensor and any individual or Legal Entity
64 | on behalf of whom a Contribution has been received by Licensor and
65 | subsequently incorporated within the Work.
66 |
67 | 2. Grant of Copyright License. Subject to the terms and conditions of
68 | this License, each Contributor hereby grants to You a perpetual,
69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70 | copyright license to reproduce, prepare Derivative Works of,
71 | publicly display, publicly perform, sublicense, and distribute the
72 | Work and such Derivative Works in Source or Object form.
73 |
74 | 3. Grant of Patent License. Subject to the terms and conditions of
75 | this License, each Contributor hereby grants to You a perpetual,
76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77 | (except as stated in this section) patent license to make, have made,
78 | use, offer to sell, sell, import, and otherwise transfer the Work,
79 | where such license applies only to those patent claims licensable
80 | by such Contributor that are necessarily infringed by their
81 | Contribution(s) alone or by combination of their Contribution(s)
82 | with the Work to which such Contribution(s) was submitted. If You
83 | institute patent litigation against any entity (including a
84 | cross-claim or counterclaim in a lawsuit) alleging that the Work
85 | or a Contribution incorporated within the Work constitutes direct
86 | or contributory patent infringement, then any patent licenses
87 | granted to You under this License for that Work shall terminate
88 | as of the date such litigation is filed.
89 |
90 | 4. Redistribution. You may reproduce and distribute copies of the
91 | Work or Derivative Works thereof in any medium, with or without
92 | modifications, and in Source or Object form, provided that You
93 | meet the following conditions:
94 |
95 | (a) You must give any other recipients of the Work or
96 | Derivative Works a copy of this License; and
97 |
98 | (b) You must cause any modified files to carry prominent notices
99 | stating that You changed the files; and
100 |
101 | (c) You must retain, in the Source form of any Derivative Works
102 | that You distribute, all copyright, patent, trademark, and
103 | attribution notices from the Source form of the Work,
104 | excluding those notices that do not pertain to any part of
105 | the Derivative Works; and
106 |
107 | (d) If the Work includes a "NOTICE" text file as part of its
108 | distribution, then any Derivative Works that You distribute must
109 | include a readable copy of the attribution notices contained
110 | within such NOTICE file, excluding those notices that do not
111 | pertain to any part of the Derivative Works, in at least one
112 | of the following places: within a NOTICE text file distributed
113 | as part of the Derivative Works; within the Source form or
114 | documentation, if provided along with the Derivative Works; or,
115 | within a display generated by the Derivative Works, if and
116 | wherever such third-party notices normally appear. The contents
117 | of the NOTICE file are for informational purposes only and
118 | do not modify the License. You may add Your own attribution
119 | notices within Derivative Works that You distribute, alongside
120 | or as an addendum to the NOTICE text from the Work, provided
121 | that such additional attribution notices cannot be construed
122 | as modifying the License.
123 |
124 | You may add Your own copyright statement to Your modifications and
125 | may provide additional or different license terms and conditions
126 | for use, reproduction, or distribution of Your modifications, or
127 | for any such Derivative Works as a whole, provided Your use,
128 | reproduction, and distribution of the Work otherwise complies with
129 | the conditions stated in this License.
130 |
131 | 5. Submission of Contributions. Unless You explicitly state otherwise,
132 | any Contribution intentionally submitted for inclusion in the Work
133 | by You to the Licensor shall be under the terms and conditions of
134 | this License, without any additional terms or conditions.
135 | Notwithstanding the above, nothing herein shall supersede or modify
136 | the terms of any separate license agreement you may have executed
137 | with Licensor regarding such Contributions.
138 |
139 | 6. Trademarks. This License does not grant permission to use the trade
140 | names, trademarks, service marks, or product names of the Licensor,
141 | except as required for reasonable and customary use in describing the
142 | origin of the Work and reproducing the content of the NOTICE file.
143 |
144 | 7. Disclaimer of Warranty. Unless required by applicable law or
145 | agreed to in writing, Licensor provides the Work (and each
146 | Contributor provides its Contributions) on an "AS IS" BASIS,
147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148 | implied, including, without limitation, any warranties or conditions
149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150 | PARTICULAR PURPOSE. You are solely responsible for determining the
151 | appropriateness of using or redistributing the Work and assume any
152 | risks associated with Your exercise of permissions under this License.
153 |
154 | 8. Limitation of Liability. In no event and under no legal theory,
155 | whether in tort (including negligence), contract, or otherwise,
156 | unless required by applicable law (such as deliberate and grossly
157 | negligent acts) or agreed to in writing, shall any Contributor be
158 | liable to You for damages, including any direct, indirect, special,
159 | incidental, or consequential damages of any character arising as a
160 | result of this License or out of the use or inability to use the
161 | Work (including but not limited to damages for loss of goodwill,
162 | work stoppage, computer failure or malfunction, or any and all
163 | other commercial damages or losses), even if such Contributor
164 | has been advised of the possibility of such damages.
165 |
166 | 9. Accepting Warranty or Additional Liability. While redistributing
167 | the Work or Derivative Works thereof, You may choose to offer,
168 | and charge a fee for, acceptance of support, warranty, indemnity,
169 | or other liability obligations and/or rights consistent with this
170 | License. However, in accepting such obligations, You may act only
171 | on Your own behalf and on Your sole responsibility, not on behalf
172 | of any other Contributor, and only if You agree to indemnify,
173 | defend, and hold each Contributor harmless for any liability
174 | incurred by, or claims asserted against, such Contributor by reason
175 | of your accepting any such warranty or additional liability.
176 |
177 | END OF TERMS AND CONDITIONS
178 |
179 | APPENDIX: How to apply the Apache License to your work.
180 |
181 | To apply the Apache License to your work, attach the following
182 | boilerplate notice, with the fields enclosed by brackets "[]"
183 | replaced with your own identifying information. (Don't include
184 | the brackets!) The text should be enclosed in the appropriate
185 | comment syntax for the file format. We also recommend that a
186 | file or class name and description of purpose be included on the
187 | same "printed page" as the copyright notice for easier
188 | identification within third-party archives.
189 |
190 | Copyright 2019 The Google Research Authors
191 |
192 | Licensed under the Apache License, Version 2.0 (the "License");
193 | you may not use this file except in compliance with the License.
194 | You may obtain a copy of the License at
195 |
196 | http://www.apache.org/licenses/LICENSE-2.0
197 |
198 | Unless required by applicable law or agreed to in writing, software
199 | distributed under the License is distributed on an "AS IS" BASIS,
200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
201 | See the License for the specific language governing permissions and
202 | limitations under the License.
203 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Wireless Software Synchronization of Multiple Distributed Cameras
2 |
3 | Reference code for the paper
4 | [Wireless Software Synchronization of Multiple Distributed Cameras](https://arxiv.org/abs/1812.09366).
5 | _Sameer Ansari, Neal Wadhwa, Rahul Garg, Jiawen Chen_, ICCP 2019.
6 |
7 | If you use this code, please cite our paper:
8 |
9 | ```
10 | @article{AnsariSoftwareSyncICCP2019,
11 | author = {Ansari, Sameer and Wadhwa, Neal and Garg, Rahul and Chen, Jiawen},
12 | title = {Wireless Software Synchronization of Multiple Distributed Cameras},
13 | journal = {ICCP},
14 | year = {2019},
15 | }
16 | ```
17 |
18 | _This is not an officially supported Google product._
19 |
20 | 
21 | _Five smartphones synchronously capture a balloon filled with red water being popped to within 250 μs timing accuracy._
22 |
23 | ## Android App to Capture Synchronized Images
24 |
25 | The app has been tested on the Google Pixel 2, 3, and 4.
26 | It may work on other Android phones with minor changes.
27 |
28 | Note: On Pixel 1 devices the viewfinder frame rate drops after a couple
29 | captures, which will likely cause time synchronization to be much
30 | lower in accuracy. This may be due to thermal throttling.
31 | Disabling saving to JPEG or lowering the frame rate may help.
32 |
33 | ### Installation instructions:
34 |
35 | 1. Download [Android Studio](https://developer.android.com/studio). When you
36 | install it, make sure to also install the Android SDK API 27.
37 | 2. Click "Open an existing Android Studio project". Select the "CaptureSync"
38 | directory.
39 | 3. There will be a pop-up with the title "Gradle Sync" complaining about a
40 | missing file called gradle-wrapper.properties. Click ok to recreate the
41 | Gradle wrapper.
42 | 4. Plug in your Pixel smartphone. You will need to enable USB debugging. See
43 | https://developer.android.com/studio/debug/dev-options for further
44 | instructions.
45 | 5. Go to the "Run" menu at the top and click "Run 'app'" to compile and install
46 | the app.
47 |
48 | Note: By default, the app will likely start in client mode, with no UI options.
49 |
50 | #### Setting up the Leader device
51 |
52 | 1. On the system pulldown menu of the leader device, disable WiFi.
53 | 2. [Start a hotspot](https://support.google.com/android/answer/9059108).
54 | 3. After this, opening the app on the leader device should show UI options, as
55 | well as which clients are connected.
56 |
57 | #### Setting up the Client(s) device
58 |
59 | 1. Enable WiFi and connect to the leader's hotspot.
60 | 2. As client devices on the network start up, they will sync up with the
61 | leader, which will show up on both the leader and client UIs.
62 | 3. (Optional) Go to wifi preferences and disable "Turn on Wi-Fi automatically"
63 | and "Connect to open networks", this will keep devices from automatically
64 | disconnecting from a hotspot without internet.
65 |
66 | #### Capturing images
67 |
68 | 1. (Optional) Press the phase align button to have each device synchronize
69 | their phase, the phase error will show in real-time.
70 | 2. (Optional) Move the exposure and sensitivity slider on the leader device to
71 | manually set 2A values.
72 | 3. Press the `Capture Still` button to request a synchronized image slightly in
73 | the future on all devices.
74 |
75 | This will save to internal storage, as well as show up under the Pictures
76 | directory in the photo gallery.
77 |
78 | Note: Fine-tuning the phase configuration JSON parameters in the `raw` resources
79 | directory will let you trade alignment-time for phase alignment accuracy.
80 |
81 | Note: AWB is used for simplicity, but could also be synchronized with devices.
82 |
83 | ### Information about saved data
84 |
85 | Synchronized images are saved to the external files directory for this app,
86 | which is:
87 |
88 | ```
89 | /storage/emulated/0/Android/data/com.googleresearch.capturesync/files
90 | ```
91 |
92 | A JPEG version of the image will also populate in the photo gallery under the
93 | `Pictures` subdirectory under `Settings -> Device Folders`.
94 |
95 | Pulling data from individual phones using:
96 |
97 | ```
98 | adb pull /storage/emulated/0/Android/data/com.googleresearch.capturesync/files /tmp/outputdir
99 | ```
100 |
101 | The images are also stored as a raw YUV file (in
102 | [packed NV21 format](https://wiki.videolan.org/YUV)) and a metadata file which
103 | can be converted to PNG or JPG using the Python script in the `scripts/`
104 | directory.
105 |
106 | #### Example Workflow
107 |
108 | 1. User sets up all devices on the same hotspot WiFi network of leader device.
109 | 2. User starts app on all devices, uses exposure sliders and presses the
110 | `Phase Align` button on the leader device.
111 | 3. User presses capture button on the leader device to collect captures.
112 | 4. If JPEG is enabled (default) the user can verify captures by going to the
113 | `Pictures` photo directory on their phone through Google Photos or similar.
114 | 5. After a capture session, the user pulls the data from each phone to the local
115 | machine using `adb pull`.
116 | 6. (Optional)The python script is used to convert the raw images using:
117 | ```
118 | python3 yuv2rgb.py img_.nv21 nv21_metadata_.txt
119 | out..
120 | ```
121 |
122 | ## How Networking and Communications work
123 |
124 | Note: Algorithm specifics can be found in our paper linked at the top.
125 |
126 | Leader and clients use heartbeats to connect with one another and keep track of
127 | state. Simple NTP is used for clock synchronization. That, phase alignment and
128 | 2A is used to make phones capture the same type of image as the same time.
129 | Capturing is done by sending a trigger time to all devices which will
130 | independently capture at that time.
131 |
132 | All of this requires communication. One component of this library is to provide
133 | a method for sending messages (RPCs) between the leader device and client
134 | devices, to allow for synchronization as well as capture triggering, AWB,
135 | state etc.
136 |
137 | The network uses wifi with UDP messages for communication. The leader IP is
138 | determined automatically by client devices.
139 |
140 | A message is sent as an RPC byte sequence consisting of an integer method ID
141 | (defined in
142 | [`SyncConstants.java`](app/src/main/java/com/googleresearch/capturesync/softwaresync/SyncConstants.java)
143 | and the string message payload. (defined in
144 | [`SoftwareSyncBase.java`](app/src/main/java/com/googleresearch/capturesync/softwaresync/SoftwareSyncBase.java)
145 | `sendRpc()`)
146 |
147 | Note: This app has the leader set up a hotspot, through this client devices can
148 | automatically determine the leader IP address from the connection, however one
149 | could manually configure IP address with a different network configuration, such
150 | as using a router that all the phones connect to.
151 |
152 |
153 | ### Capture
154 |
155 | The leader sends a `METHOD_SET_TRIGGER_TIME` RPC (Method id located in
156 | [`SoftwareSyncController.java`](app/src/main/java/com/googleresearch/capturesync/SoftwareSyncController.java)
157 | ) to all the clients containing a requested capture
158 | synchronized timestamp far enough in the future to account for potential network
159 | latency between devices. In practice network latency between devices is ~100ms
160 | or less, however the latency may be more or less depending on what devices or
161 | network configuration is used.
162 |
163 | Note: In this case the future is 500ms, giving plenty of time for network
164 | latency.
165 |
166 | Each client and leader receives the future timestamp and `CameraController.java`
167 | checks the timestamp of each frame as it comes in and pulls the closest frame at
168 | or past the desired timestamp and saves it to disk. One advantage of this method
169 | is that if any delays happen in capturing, the synchronized capture timestamp
170 | will show that the time offset between images without requiring looking at the
171 | images.
172 |
173 | Note: Zero-shutter-lag capture is possible if each device is capable of storing
174 | frames in a ring buffer. Then when a desired current/past capture timestamp is
175 | provided each device can check in the ring buffer for the closest frame
176 | timestamp and save that one.
177 |
178 | ### Heartbeat
179 |
180 | A leader listens for a heartbeat from any client, to determine if a client
181 | exists and whether starting the synchronization with that client is necessary.
182 | When it gets a heartbeat from a client that is not synchronized, it initiates an
183 | NTP handshake with the client to determine the clock offsets between the two
184 | devices
185 |
186 | A client continuously sends out `METHOD_HEARTBEAT` RPC to the leader with it's
187 | current boolean state for if it's already synchronized with the leader.
188 |
189 | A leader received `METHOD_HEARTBEAT` and responds with a `METHOD_HEARTBEAT_ACK`
190 | to the client. The leader uses this to keep track of a list of clients using the
191 | `ClientInfo` object for each client, which will also include sync information.
192 |
193 | The client waits for a `METHOD_OFFSET_UPDATE` from the leader which contains the
194 | time offset needed to get to a synchronized clock domain with the leader, after
195 | which it's heartbeat messages will show that it is synced to the leader.
196 |
197 | Whenever a client gets desynchronized, the heartbeats will notify the leader of
198 | it and they will re-initiate synchronization. Through this mechanism automated
199 | clock synchronization and maintenance is achieved.
200 |
201 | ### Simple NTP Handshake
202 |
203 | The
204 | [`SimpleNetworkTimeProtocol.java`](app/src/main/java/com/googleresearch/capturesync/softwaresync/SimpleNetworkTimeProtocol.java)
205 | is used to perform an NTP handshake between the leader and client. The local
206 | time domain of the devices is used, using the
207 | [`Ticker.java`](app/src/main/java/com/googleresearch/capturesync/softwaresync/Ticker.java)
208 | method for getting local nanosecond time.
209 |
210 | An NTP handshake consists of the leader sending a message containing the current
211 | leader timestamp t0. The client receives and appends it's receiving local
212 | timestamp t1, as well as the timestamp it sends a return message to the leader
213 | t2. The leader receives this at timestamp t3, and using these 4 times estimates
214 | the clock offset between the two devices, accounting for network latency.
215 |
216 | This result is encapsulated in
217 | [`SntpOffsetResponse.java`](app/src/main/java/com/googleresearch/capturesync/softwaresync/SntpOffsetResponse.java)
218 | which also contains the hard upper bound timing error on the offset. In practice
219 | the timing error is an order of magnitude smaller since wifi network
220 | communication is mostly symmetric with the bias accounted for by choosing the
221 | smallest sample(s).
222 |
223 | More information can be found in our paper on this topic.
224 |
225 | ### Phase Alignment
226 |
227 | The leader sends out a `METHOD_DO_PHASE_ALIGN` RPC (Method id located in
228 | `SoftwareSyncController.java`) to all the clients whenever the Align button is
229 | pressed. Each client on receipt then starts a phase alignment process (handled
230 | by `PhaseAlignController.java`) which may take a couple frames to settle.
231 |
232 | Note: The leader could instead send its current phase to all devices, and the
233 | devices could align to that, reducing the total potential error. For simplicity
234 | this app uses a hard-coded goal phase.
235 |
236 | ### Exposure / White Balance / Focus
237 |
238 | For simplicity, this app uses manual exposure, hard-coded white balance, and
239 | auto-focus. The leader uses UI sliders to set exposure and sensitivity, which
240 | automatically sends out a `METHOD_SET_2A` RPC (Method id located in
241 | [`SoftwareSyncController.java`](app/src/main/java/com/googleresearch/capturesync/SoftwareSyncController.java)
242 | ) to all the clients, which update their 2A as
243 | well. Technically 2A is a misnomer here as it is only setting exposure and
244 | sensitivity, not white balance.
245 |
246 | It is possible to use auto exposure/sensitivity and white balance, and have the
247 | leader lock and send the current 2A using the same RPC mechanism to other
248 | devices which can then set theirs manually to the same.
249 |
250 | Note: One could try synchronizing focus values as well, though in practice we
251 | found the values were not accurate enough to provide sharp focus across devices.
252 | Hence we keep auto-focus.
253 |
--------------------------------------------------------------------------------
/app/app.iml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 | generateDebugSources
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 |
96 |
97 |
98 |
99 |
100 |
101 |
102 |
103 |
104 |
105 |
106 |
107 |
108 |
109 |
110 |
111 |
112 |
113 |
114 |
115 |
116 |
117 |
118 |
119 |
120 |
121 |
122 |
123 |
124 |
125 |
126 |
127 |
128 |
129 |
130 |
131 |
132 |
133 |
134 |
135 |
136 |
137 |
--------------------------------------------------------------------------------
/app/src/main/java/com/googleresearch/capturesync/ResultProcessor.java:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright 2019 The Google Research Authors.
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package com.googleresearch.capturesync;
18 |
19 | import static java.nio.charset.StandardCharsets.UTF_8;
20 |
21 | import android.graphics.ImageFormat;
22 | import android.graphics.Rect;
23 | import android.graphics.YuvImage;
24 | import android.hardware.camera2.CaptureResult;
25 | import android.media.Image;
26 | import android.os.Handler;
27 | import android.os.HandlerThread;
28 | import android.provider.MediaStore;
29 | import android.util.Log;
30 | import com.googleresearch.capturesync.softwaresync.TimeDomainConverter;
31 | import com.googleresearch.capturesync.softwaresync.TimeUtils;
32 | import java.io.File;
33 | import java.io.FileNotFoundException;
34 | import java.io.FileOutputStream;
35 | import java.io.IOException;
36 | import java.io.PrintWriter;
37 | import java.nio.ByteBuffer;
38 | import java.nio.channels.FileChannel;
39 | import java.text.SimpleDateFormat;
40 | import java.util.TimeZone;
41 |
42 | /** A class that processes frames on its own thread. */
43 | public class ResultProcessor {
44 | private static final String TAG = "ResultProcessor";
45 |
46 | private final Handler handler;
47 | private final MainActivity context;
48 | private final TimeDomainConverter timeDomainConverter;
49 |
50 | // Copy from constants... make it a user parameter.
51 | private final boolean saveJpgFromNv21;
52 | private final int jpgQuality;
53 |
54 | public ResultProcessor(
55 | TimeDomainConverter timeDomainConverter,
56 | MainActivity context,
57 | boolean saveJpgFromYuv,
58 | int jpgQuality) {
59 | this.timeDomainConverter = timeDomainConverter;
60 | this.context = context;
61 | this.saveJpgFromNv21 = saveJpgFromYuv;
62 | this.jpgQuality = jpgQuality;
63 |
64 | HandlerThread thread = new HandlerThread(TAG);
65 | thread.start();
66 | // getLooper() blocks until the thread started and its Looper is prepared.
67 | handler = new Handler(thread.getLooper());
68 | }
69 |
70 | /** Submit a request to process a Frame on the processor's thread. */
71 | public void submitProcessRequest(Frame capture, String filename) {
72 | handler.post(() -> processStill(capture, filename));
73 | }
74 |
75 | private void processStill(final Frame frame, String basename) {
76 | File captureDir = new File(context.getExternalFilesDir(null), basename);
77 | if (!captureDir.exists() && !captureDir.mkdirs()) {
78 | throw new IllegalStateException("Could not create dir " + captureDir);
79 | }
80 | // Timestamp in local domain ie. time since boot in nanoseconds.
81 | long localSensorTimestampNs = frame.result.get(CaptureResult.SENSOR_TIMESTAMP);
82 | // Timestamp in leader domain ie. synchronized time on leader device in nanoseconds.
83 | long syncedSensorTimestampNs =
84 | timeDomainConverter.leaderTimeForLocalTimeNs(localSensorTimestampNs);
85 | // Use syncedSensorTimestamp in milliseconds for filenames.
86 | long syncedSensorTimestampMs = (long) TimeUtils.nanosToMillis(syncedSensorTimestampNs);
87 | String filenameTimeString = getTimeStr(syncedSensorTimestampMs);
88 |
89 | // Save timing metadata.
90 | {
91 | String metaFilename = "sync_metadata_" + filenameTimeString + ".txt";
92 | File metaFile = new File(captureDir, metaFilename);
93 | saveTimingMetadata(syncedSensorTimestampNs, localSensorTimestampNs, metaFile);
94 | }
95 |
96 | for (int i = 0; i < frame.output.images.size(); ++i) {
97 | Image image = frame.output.images.get(i);
98 | int format = image.getFormat();
99 | if (format == ImageFormat.RAW_SENSOR) {
100 | // Note: while using DngCreator works, streaming RAW_SENSOR is too slow.
101 | Log.e(TAG, "RAW_SENSOR saving not implemented!");
102 | } else if (format == ImageFormat.JPEG) {
103 | Log.e(TAG, "JPEG saving not implemented!");
104 | } else if (format == ImageFormat.RAW10) {
105 | Log.e(TAG, "RAW10 saving not implemented!");
106 | } else if (format == ImageFormat.YUV_420_888) {
107 | // TODO(jiawen): We know that on Pixel devices, the YUV format is NV21, consisting of a luma
108 | // plane and separate interleaved chroma planes.
109 | // <--w-->
110 | // ^ YYYYYYYZZZ
111 | // | YYYYYYYZZZ
112 | // h ...
113 | // | ...
114 | // v YYYYYYYZZZ
115 | //
116 | // <--w-->
117 | // ^ VUVUVUVZZZZZ
118 | // | VUVUVUVZZZZZ
119 | // h/2 ...
120 | // | ...
121 | // v VUVUVUVZZZZZ
122 | //
123 | // where Z is padding bytes.
124 | //
125 | // TODO(jiawen): To determine if it's NV12 vs NV21, we need JNI to compare the buffer start
126 | // addresses.
127 |
128 | context.notifyCapturing("img_" + filenameTimeString);
129 |
130 | // Save NV21 raw + metadata.
131 | {
132 | File nv21File = new File(captureDir, "img_" + filenameTimeString + ".nv21");
133 | File nv21MetadataFile =
134 | new File(captureDir, "nv21_metadata_" + filenameTimeString + ".txt");
135 | saveNv21(image, nv21File, nv21MetadataFile);
136 | context.notifyCaptured(nv21File.getName());
137 | }
138 |
139 | // TODO(samansari): Make save JPEG a checkbox in the UI.
140 | if (saveJpgFromNv21) {
141 | YuvImage yuvImage = yuvImageFromNv21Image(image);
142 | File jpgFile = new File(captureDir, "img_" + filenameTimeString + ".jpg");
143 |
144 | // Push saving JPEG onto queue to let the frame close faster, necessary for some devices.
145 | handler.post(() -> saveJpg(yuvImage, jpgFile));
146 | }
147 | } else {
148 | Log.e(TAG, String.format("Cannot save unsupported image format: %d", image.getFormat()));
149 | }
150 | }
151 |
152 | frame.close();
153 | }
154 |
155 | private static boolean saveNv21(Image yuvImage, File nv21File, File nv21metadataFile) {
156 | long t0 = System.nanoTime();
157 |
158 | Image.Plane[] planes = yuvImage.getPlanes();
159 | Image.Plane luma = planes[0];
160 | Image.Plane chromaU = planes[1];
161 | Image.Plane chromaV = planes[2];
162 |
163 | int width = yuvImage.getWidth();
164 | int height = yuvImage.getHeight();
165 |
166 | // Luma should be tightly packed.
167 | assert (luma.getPixelStride() == 1);
168 | // TODO(jiawen): Consider relaxing this restriction and write row by row, skipping the row
169 | // padding. This requires looping over the luma plane one row at a time.
170 | assert (luma.getRowStride() == width);
171 |
172 | assert (chromaU.getPixelStride() == 2);
173 | assert (chromaU.getRowStride() == width);
174 | assert (chromaV.getPixelStride() == 2);
175 | assert (chromaV.getRowStride() == width);
176 |
177 | ByteBuffer lumaBuffer = luma.getBuffer().duplicate();
178 | ByteBuffer chromaUBuffer = chromaU.getBuffer().duplicate();
179 | ByteBuffer chromaVBuffer = chromaV.getBuffer().duplicate();
180 |
181 | assert (lumaBuffer.capacity() == width * height);
182 | assert (chromaUBuffer.capacity() + 1 == width * height / 2);
183 | assert (chromaVBuffer.capacity() + 1 == width * height / 2);
184 |
185 | {
186 | // Set chromaUBuffer's position to the last byte. slice() will make a new buffer that's a
187 | // view
188 | // of the last byte. Send that last byte to FileChannel.
189 | ByteBuffer chromaUBufferCopy = chromaUBuffer.duplicate();
190 | chromaUBufferCopy.position(chromaUBufferCopy.capacity() - 1);
191 | ByteBuffer lastChromaUByte = chromaUBufferCopy.slice();
192 |
193 | try (FileOutputStream outputStream = new FileOutputStream(nv21File)) {
194 | FileChannel outputChannel = outputStream.getChannel();
195 |
196 | outputChannel.write(lumaBuffer);
197 | // The V buffer contains the U data since it's arranged VUVUVUVU...
198 | // It contains all but the last U byte.
199 | outputChannel.write(chromaVBuffer);
200 | outputChannel.write(lastChromaUByte);
201 | } catch (IOException e) {
202 | // TODO(jiawen,samansari): Toast.
203 | Log.w(TAG, "Error saving YUV image to: " + nv21File.getAbsolutePath());
204 | return false;
205 | }
206 | }
207 |
208 | // Save NV21 metadata.
209 | {
210 | try (PrintWriter writer = new PrintWriter(nv21metadataFile, UTF_8.name())) {
211 | writer.printf("width: %d\n", width);
212 | writer.printf("height: %d\n", height);
213 | writer.printf("pixel_format: NV21 (tightly packed)\n");
214 | writer.printf("luma_buffer_bytes: %d\n", lumaBuffer.capacity());
215 | writer.printf("interleaved_chroma_buffers_bytes: %d\n", chromaVBuffer.capacity() + 1);
216 | } catch (IOException e) {
217 | // TODO(jiawen,samansari): Toast.
218 | Log.w(TAG, "Error saving metadata to: " + nv21metadataFile.getAbsolutePath());
219 | return false;
220 | }
221 | }
222 |
223 | long t1 = System.nanoTime();
224 | Log.i(TAG, String.format("saveNv21 took %f ms.", (t1 - t0) * 1e-6f));
225 |
226 | return true;
227 | }
228 |
229 | private boolean saveJpg(YuvImage yuvImage, File jpgFile) {
230 | // Save JPEG and also add to the photos gallery by inserting into MediaStore.
231 | long t0 = System.nanoTime();
232 | if (saveJpg(yuvImage, jpgQuality, jpgFile)) {
233 | try {
234 | MediaStore.Images.Media.insertImage(
235 | context.getContentResolver(),
236 | jpgFile.getAbsolutePath(),
237 | jpgFile.getName(),
238 | "Full path: " + jpgFile.getAbsolutePath());
239 | } catch (FileNotFoundException e) {
240 | Log.e(TAG, "Unable to find file to link in media store.");
241 | }
242 | long t1 = System.nanoTime();
243 | Log.i(TAG, String.format("Saving JPG to disk took %f ms.", (t1 - t0) * 1e-6f));
244 | context.notifyCaptured(jpgFile.getName());
245 | return true;
246 | }
247 | return false;
248 | }
249 |
250 | private static boolean saveJpg(YuvImage src, int quality, File file) {
251 | long t0 = System.nanoTime();
252 | try (FileOutputStream outputStream = new FileOutputStream(file)) {
253 | Rect rect = new Rect(0, 0, src.getWidth(), src.getHeight());
254 | boolean ok = src.compressToJpeg(rect, quality, outputStream);
255 | if (!ok) {
256 | // TODO(jiawen,samansari): Toast.
257 | Log.w(TAG, "Error saving JPEG to: " + file.getAbsolutePath());
258 | }
259 | long t1 = System.nanoTime();
260 | Log.i(TAG, String.format("saveJpg took %f ms.", (t1 - t0) * 1e-6f));
261 | return ok;
262 | } catch (IOException e) {
263 | // TODO(jiawen,samansari): Toast.
264 | Log.w(TAG, "Error saving JPEG image to: " + file.getAbsolutePath());
265 | return false;
266 | }
267 | }
268 |
269 | // Utility method to convert an NV21 android.media.Image to an android.graphics.YuvImage. The
270 | // latter is just a wrapper around a byte[] but can compress to JPEG.
271 | private static YuvImage yuvImageFromNv21Image(Image src) {
272 | long t0 = System.nanoTime();
273 |
274 | Image.Plane[] planes = src.getPlanes();
275 | Image.Plane luma = planes[0];
276 | Image.Plane chromaU = planes[1];
277 | Image.Plane chromaV = planes[2];
278 |
279 | int width = src.getWidth();
280 | int height = src.getHeight();
281 |
282 | // Luma should be tightly packed and chroma should be tightly interleaved.
283 | assert (luma.getPixelStride() == 1);
284 | assert (chromaU.getPixelStride() == 2);
285 | assert (chromaV.getPixelStride() == 2);
286 |
287 | // Duplicate (shallow copy) each buffer so as to not disturb the underlying position/limit/etc.
288 | ByteBuffer lumaBuffer = luma.getBuffer().duplicate();
289 | ByteBuffer chromaUBuffer = chromaU.getBuffer().duplicate();
290 | ByteBuffer chromaVBuffer = chromaV.getBuffer().duplicate();
291 |
292 | // Yes, y, v, then u since it's NV21.
293 | int[] yvuRowStrides =
294 | new int[] {luma.getRowStride(), chromaV.getRowStride(), chromaU.getRowStride()};
295 |
296 | // Compute bytes needed to concatenate all the (potentially padded) YUV data in one buffer.
297 | int lumaBytes = height * luma.getRowStride();
298 | int interleavedChromaBytes = (height / 2) * chromaV.getRowStride();
299 | assert (lumaBuffer.capacity() == lumaBytes);
300 | int packedYVUBytes = lumaBytes + interleavedChromaBytes;
301 | byte[] packedYVU = new byte[packedYVUBytes];
302 |
303 | int packedYVUOffset = 0;
304 | lumaBuffer.get(
305 | packedYVU,
306 | packedYVUOffset,
307 | lumaBuffer.capacity()); // packedYVU[0..lumaBytes) <-- lumaBuffer.
308 | packedYVUOffset += lumaBuffer.capacity();
309 |
310 | // Write the V buffer. Since the V buffer contains U data, write all of V and then check how
311 | // much U data is left over. There be at most 1 byte plus padding.
312 | chromaVBuffer.get(packedYVU, packedYVUOffset, /*length=*/ chromaVBuffer.capacity());
313 | packedYVUOffset += chromaVBuffer.capacity();
314 |
315 | // Write the remaining portion of the U buffer (if any).
316 | int chromaUPosition = chromaVBuffer.capacity() - 1;
317 | if (chromaUPosition < chromaUBuffer.capacity()) {
318 | chromaUBuffer.position(chromaUPosition);
319 |
320 | int remainingBytes = Math.min(chromaUBuffer.remaining(), lumaBytes - packedYVUOffset);
321 |
322 | if (remainingBytes > 0) {
323 | chromaUBuffer.get(packedYVU, packedYVUOffset, remainingBytes);
324 | }
325 | }
326 | YuvImage yuvImage = new YuvImage(packedYVU, ImageFormat.NV21, width, height, yvuRowStrides);
327 |
328 | long t1 = System.nanoTime();
329 | Log.i(TAG, String.format("yuvImageFromNv212Image took %f ms.", (t1 - t0) * 1e-6f));
330 |
331 | return yuvImage;
332 | }
333 |
334 | private static String getTimeStr(long timestampMs) {
335 | SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyyMMdd_HHmmss_SSS");
336 | simpleDateFormat.setTimeZone(TimeZone.getDefault());
337 | return simpleDateFormat.format(timestampMs);
338 | }
339 |
340 | // Save metadata.
341 | private static void saveTimingMetadata(
342 | long leaderSensorTimestamp, long localSensorTimestamp, File metaFile) {
343 | try (PrintWriter writer = new PrintWriter(metaFile, UTF_8.name())) {
344 | writer.printf("leader_sensor_timestamp_ns: %d\n", leaderSensorTimestamp);
345 | writer.printf("local_sensor_timestamp_ns: %d\n", localSensorTimestamp);
346 | } catch (IOException e) {
347 | Log.e(TAG, "Error saving timing metadata to: " + metaFile.getAbsolutePath());
348 | return;
349 | }
350 | Log.v(TAG, "Saved timing metadata to: " + metaFile.getAbsolutePath());
351 | }
352 | }
353 |
--------------------------------------------------------------------------------
/app/src/main/java/com/googleresearch/capturesync/ImageMetadataSynchronizer.java:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright 2019 The Google Research Authors.
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package com.googleresearch.capturesync;
18 |
19 | import android.hardware.camera2.CameraCaptureSession;
20 | import android.hardware.camera2.CameraCaptureSession.CaptureCallback;
21 | import android.hardware.camera2.CaptureRequest;
22 | import android.hardware.camera2.CaptureResult;
23 | import android.hardware.camera2.TotalCaptureResult;
24 | import android.media.Image;
25 | import android.media.ImageReader;
26 | import android.os.Handler;
27 | import android.util.Log;
28 | import android.util.Pair;
29 | import java.util.ArrayList;
30 | import java.util.LinkedList;
31 | import java.util.List;
32 |
33 | /**
34 | * ImageMetadataSynchronizer synchronizes {@link Image} instances (from an {@link ImageReader}) with
35 | * their metadata ({@link TotalCaptureResult} instances from {@code CaptureListener}) based on their
36 | * timestamps. In practice, metadata usually (but not always!) comes back before their corresponding
37 | * Image instances.
38 | *
39 | *
Standard usage is: Create ImageReader instances (YUV, RAW, etc). Do *not* set listeners on any
40 | * of them:
41 | *
42 | *
CaptureRequest instances must be tagged with an ImageMetadataSynchronizer.CaptureRequestTag.
70 | * CaptureRequestTag specifies, as an integer list of indices from 'readers', which ImageReaders
71 | * that CaptureRequest will write its output to. If you need your own tags, use
72 | * ImageMetadataSynchronizer.CaptureRequestTag's userTag field.
73 | *
74 | *
When submitting capture requests, the capture callback must be set to that of the
75 | * synchronizer.
76 | *
77 | *
ImageMetadataSynchronizer takes ownership of the ImageReader instances passed into it.
92 | * When {@code ImageMetadataSynchronizer.close()} is called, the internal list of ImageReader
93 | * instances are closed, along with all outstanding images. You should not hold onto external
94 | * references to ImageReader instances - it is an error to close() them externally (double close).
95 | *
96 | *
To handle TotalCaptureResult instances without waiting to synchronize with potentially delayed
97 | * Image instances (e.g., to give user feedback), simply use an external CaptureCallback then
98 | * forward the calls to {@code sync.getCaptureCallback()}.
99 | *
100 | *
The data structure works by maintaining a queue of TotalCaptureResult instances and N queues
101 | * of Image instances. It enforces the invariant that at least one of the queues should be empty
102 | * when a piece of data arrives. When a TotalCaptureResult or any Image arrives, it is put into its
103 | * corresponding queue. The queues are "swept" until at least one of them is empty.
104 | *
105 | *
Queues are swept by considering the TotalCaptureResult as the master. Peek at the head of the
106 | * TotalCaptureResult queue. If it's null, there is no match. Otherwise, look in the
107 | * TotalCaptureResult's CaptureRequestTag to determine which queue it's expecting an image from.
108 | *
109 | *
110 | * Peek at the head from each queue. If any of them are null, then they have yet to arrive:
111 | * Return no match.
112 | * Otherwise, look at their timestamps.
113 | * If result.timestamp is *equal to* all firstItems:
114 | * Then it is a *match*, dequeue the result and each image. Return the match.
115 | * Else if result.timestamp is *less than* any timestamp:
116 | * Then an Image was dropped, report this on all registered callbacks.
117 | * Else result.timestamp is *greater than* any timestamp:
118 | * Then some TotalCaptureResult instances were dropped and the Image is orphaned.
119 | * Drop (close()) the Image on the corresponding queue.
120 | * *Replace* the Image with the next Image on that queue and try again.
121 | *
122 | */
123 | public class ImageMetadataSynchronizer {
124 | // TODO(jiawen): Change the constructor interface to a builder so that this class instantiates
125 | // instances of ImageReader on behalf of the caller.
126 | // TODO(jiawen): wrap ImageMetadataSynchronizer-owned images in an interface that's not closeable.
127 | private static final String TAG = "ImageMetadataSynchronizer";
128 |
129 | /**
130 | * To use {@link ImageMetadataSynchronizer}, each {@link CaptureRequest} must be tagged with an
131 | * instance of {@link CaptureRequestTag}, which tells the API which targets {@link Image}
132 | * instances will be written. User tags can still be specified as a second-level tag in {@link
133 | * #userTag}.
134 | */
135 | public static class CaptureRequestTag {
136 |
137 | final ArrayList targets;
138 | final Object userTag;
139 |
140 | /** Construct an empty CaptureRequestTag with targets and an explicit user tag. */
141 | public CaptureRequestTag(List targetIndices, Object userTag) {
142 | targets = new ArrayList<>(targetIndices);
143 | this.userTag = userTag;
144 | }
145 |
146 | /**
147 | * Extracts a CaptureRequestTag out of a TotalCaptureResult. Returns null if one isn't not set
148 | * or is not a CaptureRequestTag.
149 | */
150 | static CaptureRequestTag getCaptureRequestTag(CaptureResult result) {
151 | if (result == null) {
152 | return null;
153 | }
154 | Object tag = result.getRequest().getTag();
155 | if (!(tag instanceof CaptureRequestTag)) {
156 | return null;
157 | }
158 | return (CaptureRequestTag) tag;
159 | }
160 |
161 | /**
162 | * Convenience method to extract the Object userTag directly out of a TotalCaptureResult that
163 | * should (but not necessarily) have a ImageMetadataSynchronizer.CaptureRequestTag as its
164 | * tag. Returns null if the CaptureRequestTag is null. Otherwise, returns the userTag, which may
165 | * also be null.
166 | */
167 | public static Object getUserTag(CaptureResult result) {
168 | CaptureRequestTag crt = getCaptureRequestTag(result);
169 | if (crt == null) {
170 | return null;
171 | } else {
172 | return crt.userTag;
173 | }
174 | }
175 | }
176 |
177 | /**
178 | * Simple container for a synchronized collection of a TotalCaptureResult and a set of Image's. It
179 | * is explicitly not AutoCloseable. close() is provided to conveniently close() everything.
180 | */
181 | public static class Output {
182 |
183 | /** The TotalCaptureResult. */
184 | public TotalCaptureResult result;
185 |
186 | /**
187 | * A sparse list of {@link Image} instances that were synchronized. Only elements at indices
188 | * corresponding the ones declared in the CaptureRequest are non-null.
189 | *
190 | *
images has size() equal to the number of ImageReaders declared at initialization.
191 | *
192 | *
If any Image's were dropped by the HAL (mImagesWereDropped is true), those elements will
193 | * also be null. I.e., For all int i: images[droppedImageReaderIndices[i]] == null.
194 | */
195 | public final ArrayList images;
196 |
197 | /**
198 | * Indices of ImageReader's that were dropped by the HAL because they were not acquired() fast
199 | * enough.
200 | */
201 | ArrayList droppedImageReaderIndices;
202 |
203 | private final ImageMetadataSynchronizer metadataSynchronizer;
204 |
205 | /** Create an empty SynchronizedOutput with no result and all Image's to null. */
206 | Output(int nImages, ImageMetadataSynchronizer synchronizer) {
207 | images = new ArrayList<>();
208 | for (int i = 0; i < nImages; ++i) {
209 | images.add(null);
210 | }
211 | droppedImageReaderIndices = new ArrayList<>();
212 | metadataSynchronizer = synchronizer;
213 | }
214 |
215 | /** Convenience method to {@code close()} all underyling {@code Image} instances. */
216 | public void close() {
217 | droppedImageReaderIndices = null;
218 | for (int i = 0; i < images.size(); ++i) {
219 | Image img = images.get(i);
220 | if (img != null) {
221 | img.close();
222 | metadataSynchronizer.notifyImageClosed(i);
223 | }
224 | }
225 | images.clear();
226 | result = null;
227 | }
228 | }
229 |
230 | /** Callback interface for ImageMetadataSynchronizer.SynchronizedOutput. */
231 | public interface Callback {
232 |
233 | /** Every Image in Output.images is acquired(). You need to close() it. */
234 | void onDataAvailable(Output output);
235 | }
236 |
237 | /** Whether this synchronizer is closed. Initially false. */
238 | private boolean closed;
239 |
240 | /** Input CaptureCallback: camera2 calls this to deliver metadata. */
241 | private CaptureCallback captureCallback;
242 |
243 | /**
244 | * Queue of pending TotalCaptureResult instances to be delivered once the corresponding Image
245 | * instances arrive.
246 | */
247 | @SuppressWarnings("JdkObsolete")
248 | private final LinkedList pendingCaptureResultQueue = new LinkedList<>();
249 |
250 | /** A copy of the {@code List} that was passed in. */
251 | private final List imageReaders = new ArrayList<>();
252 |
253 | /**
254 | * ArrayList of queues. One per image reader. Each queue contains pending Image instances to be
255 | * delivered once everything arrives. This list corresponds 1-to-1 with imageReaders: there is one
256 | * queue per ImageReader.
257 | */
258 | private final List> pendingImageQueues = new ArrayList<>();
259 |
260 | /** The number of images acquired for each ImageReader. */
261 | private final List imagesAcquired = new ArrayList<>();
262 |
263 | /**
264 | * If registered, then when we finally synchronize a result, Post a call to mOutputCallback on
265 | * mOutputHandler. If mOutputHandler is null, calls it on the current thread.
266 | */
267 | private final List> callbacks = new ArrayList<>();
268 |
269 | private synchronized void notifyImageClosed(int readerIndex) {
270 | int nCurrentlyAcquired = imagesAcquired.get(readerIndex);
271 | if (nCurrentlyAcquired < 1) {
272 | throw new IllegalStateException(
273 | "Output.close() called when synchronizer thinks there are none acquired.");
274 | }
275 | imagesAcquired.set(readerIndex, nCurrentlyAcquired - 1);
276 | }
277 |
278 | /**
279 | * Create a ImageMetadataSynchronizer for the List of ImageReaders. 'imageReaders' should not
280 | * contain any duplicates. Each reader's OnImageAvailableListener is set to an internal
281 | * ImageReader.OnImageAvailableListener of the synchronizer and it is an error to change it
282 | * externally. We take ownership of each ImageReader and will close them when this
283 | * ImageMetadataSynchronizer is closed.
284 | *
285 | *
imageHandler can be set to an arbitrary non-null Handler and is shared across all
286 | * ImageReader instances.
287 | *
288 | *
Callback.onDataAvailable() is called with Image's in the same order as imageReaders.
289 | */
290 | @SuppressWarnings("JdkObsolete")
291 | public ImageMetadataSynchronizer(List imageReaders, Handler imageHandler) {
292 | closed = false;
293 |
294 | createCaptureCallback();
295 |
296 | this.imageReaders.addAll(imageReaders);
297 | // Create a queue and a listener per ImageReader.
298 | int nReaders = imageReaders.size();
299 | for (int i = 0; i < nReaders; ++i) {
300 | final int readerIndex = i;
301 | ImageReader reader = imageReaders.get(readerIndex);
302 | pendingImageQueues.add(new LinkedList<>());
303 | imagesAcquired.add(0);
304 |
305 | ImageReader.OnImageAvailableListener listener =
306 | reader1 -> {
307 | synchronized (ImageMetadataSynchronizer.this) {
308 | if (closed) {
309 | return;
310 | }
311 | int nImagesAcquired = imagesAcquired.get(readerIndex);
312 | if (nImagesAcquired < reader1.getMaxImages()) {
313 | Image image = reader1.acquireNextImage();
314 | imagesAcquired.set(readerIndex, nImagesAcquired + 1);
315 | handleImageLocked(readerIndex, image);
316 | }
317 | }
318 | };
319 | reader.setOnImageAvailableListener(listener, imageHandler);
320 | }
321 | }
322 |
323 | /** Clear all pending queues and clear all queued Image's. */
324 | public synchronized void close() {
325 | if (closed) {
326 | Log.w(TAG, "Already closed!");
327 | return;
328 | }
329 | closed = true;
330 |
331 | // Close every image in every queue, then clear the queue.
332 | for (LinkedList q : pendingImageQueues) {
333 | for (Image img : q) {
334 | img.close();
335 | }
336 | q.clear();
337 | }
338 | // Clear the collection of queues.
339 | pendingImageQueues.clear();
340 |
341 | // Clear the TotalCaptureResult queue.
342 | pendingCaptureResultQueue.clear();
343 |
344 | for (ImageReader ir : imageReaders) {
345 | ir.close();
346 | }
347 | }
348 |
349 | /**
350 | * CaptureCallback used by camera2 to deliver TotalCaptureResult's. Either directly pass this
351 | * callback to CameraCaptureSession.capture(), or if the client receives CaptureCallbacks on a
352 | * separate path, forward the onCaptureProgressed(), onCaptureCompleted(), onCaptureFailed() etc,
353 | * here.
354 | */
355 | public CaptureCallback getCaptureCallback() {
356 | return captureCallback;
357 | }
358 |
359 | /**
360 | * Register a callback to consume synchronized data. It will be delivered on handler, or the
361 | * current thread if handler is null.
362 | *
363 | *
Duplicates are not checked: if the same callback is registered N times, it will be
364 | * called N times.
365 | */
366 | public synchronized void registerCallback(Callback callback, Handler handler) {
367 | // TODO(jiawen): Consider making only a single callback available, since an Output can only be
368 | // closed once.
369 | callbacks.add(Pair.create(callback, handler));
370 | }
371 |
372 | /** Initialize captureCallback with a function that just calls handleCaptureCompleted(). */
373 | private void createCaptureCallback() {
374 | captureCallback =
375 | new CaptureCallback() {
376 | @Override
377 | public void onCaptureCompleted(
378 | CameraCaptureSession session, CaptureRequest request, TotalCaptureResult result) {
379 | if (closed) {
380 | return;
381 | }
382 | synchronized (ImageMetadataSynchronizer.this) {
383 | handleCaptureResultLocked(result);
384 | }
385 | }
386 | };
387 | }
388 |
389 | /**
390 | * Enforces the invariant by sweeping all internal queues when a TotalCaptureResult arrives.
391 | * result cannot be null.
392 | */
393 | private void handleCaptureResultLocked(TotalCaptureResult result) {
394 | // TODO(jiawen): Add annotations.
395 | CaptureRequestTag crt = CaptureRequestTag.getCaptureRequestTag(result);
396 | if (crt == null) {
397 | throw new IllegalArgumentException("CaptureResult is missing a CaptureRequestTag.");
398 | }
399 |
400 | // It has no targets, doesn't affect the queue.
401 | if (crt.targets.isEmpty()) {
402 | return;
403 | }
404 |
405 | pendingCaptureResultQueue.addLast(result);
406 | sweepQueuesLocked();
407 | }
408 |
409 | /**
410 | * Enforces the invariant by sweeping all internal queues when an Image arrives. image cannot be
411 | * null.
412 | */
413 | private void handleImageLocked(int readerIndex, Image image) {
414 | pendingImageQueues.get(readerIndex).addLast(image);
415 | sweepQueuesLocked();
416 | }
417 |
418 | /**
419 | * Sweeps over all queues. The outer (master) loop sweeps over the pending TotalCaptureResult's
420 | * until it runs out of matches. The inner loop sweeps over all pending Image queues corresponding
421 | * to the declared output indices in the TotalCaptureResult.getRequest().getTag(). If there is a
422 | * match, notifies all callbacks. Otherwise, exits the outer loop.
423 | */
424 | private void sweepQueuesLocked() {
425 | int nImageQueues = pendingImageQueues.size();
426 |
427 | // Outer loop: iterate over the TotalCaptureResult queue.
428 | while (!pendingCaptureResultQueue.isEmpty()) {
429 | TotalCaptureResult result = pendingCaptureResultQueue.peekFirst();
430 |
431 | // Create a potential SynchronizedOutput.
432 | Output potentialOutput = new Output(nImageQueues, this);
433 | potentialOutput.result = result;
434 |
435 | boolean matchFound = sweepImageQueues(potentialOutput);
436 | if (matchFound) {
437 | pendingCaptureResultQueue.removeFirst();
438 | postCallbackWithSynchronizedOutputLocked(potentialOutput);
439 | } else {
440 | return;
441 | }
442 | }
443 | }
444 |
445 | /**
446 | * Sweeps over all Image queues for a given potential output with its TotalCaptureResult
447 | * populated. The loop iterates over each of the Image queues corresponding to
448 | * potentialOutput.result.getRequest().getTag().targets.
449 | *
450 | *
If the head of each queue matches the TotalCaptureResult's timestamp, it is pulled off the
451 | * queue and returned as an Output. If {@code TotalCaptureResult.timestamp < an image.timestamp},
452 | * then an Image was dropped on that queue, return it. If {@codeTotalCaptureResult.timestamp > any
453 | * image.timestamp}, then a TotalCaptureResult was dropped and we drop the corresponding Image's.
454 | *
455 | * @return Whether a match was found. If a match is found, the corresponding Image's are dequeued.
456 | */
457 | private boolean sweepImageQueues(Output potentialOutput) {
458 | TotalCaptureResult result = potentialOutput.result;
459 | CaptureRequestTag crt = (CaptureRequestTag) result.getRequest().getTag();
460 | long resultTimestamp = result.get(CaptureResult.SENSOR_TIMESTAMP);
461 |
462 | // Before entering the loop, populate potentialOutput's images array
463 | // with the heads of the corresponding ImageQueues.
464 | for (int readerIndex : crt.targets) {
465 | Image img = pendingImageQueues.get(readerIndex).peekFirst();
466 | potentialOutput.images.set(readerIndex, img);
467 | }
468 | potentialOutput.droppedImageReaderIndices.clear();
469 |
470 | // If all corresponding image queues heads are null: no match is found:
471 | // fall through and return null.
472 | while (allIndexedImagesNotNull(potentialOutput, crt.targets)) {
473 | // Check if resultTimestamp > any image timestamps. If so, a TotalCaptureResult was skipped.
474 | // Drop the corresponding Images by:
475 | // close()-ing the Image, taking it off its relevant queue, and replacing it in
476 | // potentialOutput with the next Image at the head of the corresponding queue.
477 | // This loop drops one item per queue and returns to the outer loop.
478 | // The next time in the outer loop, the invariant is again enforced:
479 | // If any queue is empty, then items were dropped but the corresponding
480 | // item in one of the image queues has yet to arrive.
481 | // Else, if we get here again (resultTimestamp > imageTimestamp):
482 | // then drop the image again and repeat.
483 | // Else (resultTimestamp <= imageTimestamp)
484 | // we'll break out of this loop and it will be handled below.
485 | boolean captureResultSkipped = false;
486 | for (int readerIndex : crt.targets) {
487 | if (resultTimestamp > potentialOutput.images.get(readerIndex).getTimestamp()) {
488 | // Close the image corresponding to the dropped TotalCaptureResult.
489 | Log.v(TAG, "Dropping Image due to dropped TotalCaptureResult.");
490 | captureResultSkipped = true;
491 | potentialOutput.images.get(readerIndex).close();
492 | // Take it off its queue.
493 | pendingImageQueues.get(readerIndex).removeFirst();
494 | // Replace it with the new head of the corresponding pending Image queue.
495 | potentialOutput.images.set(readerIndex, pendingImageQueues.get(readerIndex).peekFirst());
496 | }
497 | }
498 | // TODO(jiawen): Can potentially report this in the Callback.
499 | // If any images were dropped, skip the rest of the loop and go again.
500 | if (captureResultSkipped) {
501 | continue;
502 | }
503 |
504 | // At this point, all Image's are non-null, and resultTimestamp <= imageTimestamp.
505 | // Check if resultTimestamp < imageTimestamp, then an image was dropped.
506 | // Otherwise, it's a match.
507 | for (int readerIndex : crt.targets) {
508 | if (resultTimestamp < potentialOutput.images.get(readerIndex).getTimestamp()) {
509 | // Add the index to the dropped list.
510 | potentialOutput.droppedImageReaderIndices.add(readerIndex);
511 | // Set its corresponding mImage to null. The image is still on the queue.
512 | potentialOutput.images.set(readerIndex, null);
513 | } else {
514 | // We have timestamps are equal and we have a match.
515 | // Pull the Image off its queue.
516 | pendingImageQueues.get(readerIndex).removeFirst();
517 | }
518 | }
519 | return true;
520 | }
521 | return false;
522 | }
523 |
524 | /**
525 | * Returns true if for every index idx in targetIndices, potentialOutput.images.get(idx) is not
526 | * null.
527 | */
528 | private static boolean allIndexedImagesNotNull(
529 | Output potentialOutput, ArrayList targetIndices) {
530 | for (int idx : targetIndices) {
531 | if (potentialOutput.images.get(idx) == null) {
532 | return false;
533 | }
534 | }
535 | return true;
536 | }
537 |
538 | /** Calls every registered callback with output, on their corresponding threads. */
539 | private void postCallbackWithSynchronizedOutputLocked(final Output output) {
540 | if (callbacks.isEmpty()) {
541 | // Nothing registered, close() it.
542 | output.close();
543 | return;
544 | }
545 |
546 | for (Pair p : callbacks) {
547 | final Callback callback = p.first;
548 | if (callback != null) {
549 | Handler handler = p.second;
550 | if (handler != null) {
551 | handler.post(() -> callback.onDataAvailable(output));
552 | } else {
553 | // handler is null, call it on the current thread.
554 | callback.onDataAvailable(output);
555 | }
556 | }
557 | }
558 | }
559 | }
560 |
--------------------------------------------------------------------------------