Skip to content

Commit ec113b0

Browse files
trekawekclaude
andcommitted
Add built-in webcam capture for the Game Boy Camera (#60)
The Pocket Camera sensor could only read from an image file kept fresh by an external command (ffmpeg). This adds a real webcam feed with no external tooling, via the sarxos webcam-capture library. - core: a small CameraSource interface (getFrame -> BufferedImage) that the front end registers on PocketCamera; the camera prefers it over the image file, then the file, then the synthetic test pattern. The core stays free of any capture dependency. The frame scale/luminance step is factored into a shared toLuma() helper. - swing: WebcamCameraSource opens the default webcam in async mode (a cheap non-blocking getImage() per capture), picking the smallest supported resolution that still covers the 128x112 sensor. A "Use webcam as camera" toggle in the Game menu wires it in and closes it again; if no webcam opens it reports the error and unchecks. Verified end to end: with a synthetic CameraSource the Game Boy Camera's SHOOT viewfinder renders the live frame dithered in camera style. Refs #60 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J1wLWscyUGS7CFwc3CjJR8
1 parent 0f67470 commit ec113b0

5 files changed

Lines changed: 171 additions & 17 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package eu.rekawek.coffeegb.core.memory.cart.type;
2+
3+
import java.awt.image.BufferedImage;
4+
5+
/**
6+
* A live source of frames for the Pocket Camera sensor. A front end (e.g. the Swing UI
7+
* with a real webcam) registers one through {@link PocketCamera#setCameraSource}; the
8+
* camera scales each returned frame to the sensor's 128x112 and dithers it like the real
9+
* ASIC. Keeping this out of the core means the core stays free of any capture dependency.
10+
*/
11+
public interface CameraSource {
12+
13+
/**
14+
* @return the most recent camera frame, or {@code null} if none is available yet
15+
* (the camera then falls back to the image file / test pattern)
16+
*/
17+
BufferedImage getFrame();
18+
}

core/src/main/java/eu/rekawek/coffeegb/core/memory/cart/type/PocketCamera.java

Lines changed: 45 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,12 @@
1313
/**
1414
* The Pocket Camera cartridge (type 0xFC): MBC5-like ROM banking, 128 KB of banked RAM,
1515
* and the camera's register file mapped in place of RAM when RAM-bank bit 4 is set.
16-
* A capture completes instantly (the busy bit always reads 0). The sensor image comes
17-
* from the file named by the {@code coffeegb.camera.image} system property, re-read on
18-
* every capture, so pointing it at a file periodically refreshed from a webcam (e.g.
19-
* {@code ffmpeg -f v4l2 -i /dev/video0 -update 1 cam.jpg}) gives a live camera; without
20-
* the property a synthetic test pattern is photographed. The image is scaled to the
16+
* A capture completes instantly (the busy bit always reads 0). The sensor image comes,
17+
* in priority order, from a live {@link CameraSource} registered by the front end (the
18+
* Swing UI wires a real webcam through it), then the file named by the
19+
* {@code coffeegb.camera.image} system property (re-read on every capture, e.g. one kept
20+
* fresh by {@code ffmpeg -f v4l2 -i /dev/video0 -update 1 cam.jpg}), and finally a
21+
* synthetic test pattern. The image is scaled to the
2122
* sensor's 128x112, converted to luminance and dithered through the game-supplied
2223
* threshold matrix (registers 0x06-0x35), and the result is written as 2bpp tiles to the
2324
* capture buffer at RAM offset 0x100 like the real ASIC does.
@@ -54,6 +55,16 @@ public class PocketCamera implements MemoryController {
5455

5556
private transient int[] sourceLuma;
5657

58+
// a live capture source (e.g. a webcam) registered by the front end; when present it
59+
// takes priority over the image file. Static because the cartridge is created deep in
60+
// the emulator and there is only ever one camera.
61+
private static volatile CameraSource cameraSource;
62+
63+
/** Registers (or clears, with {@code null}) the live sensor source. */
64+
public static void setCameraSource(CameraSource source) {
65+
cameraSource = source;
66+
}
67+
5768
public PocketCamera(Rom rom, Battery battery) {
5869
this.rom = rom.getRom();
5970
this.ram = new int[0x20000];
@@ -148,6 +159,18 @@ private void capture() {
148159
}
149160

150161
private int[] sensorImage() {
162+
// a registered live source (webcam) wins over the file; it is re-read every capture
163+
CameraSource source = cameraSource;
164+
if (source != null) {
165+
try {
166+
BufferedImage frame = source.getFrame();
167+
if (frame != null) {
168+
return toLuma(frame);
169+
}
170+
} catch (Exception e) {
171+
// fall through to the file / test pattern
172+
}
173+
}
151174
String path = System.getProperty("coffeegb.camera.image");
152175
if (path != null) {
153176
File f = new File(path);
@@ -156,18 +179,7 @@ private int[] sensorImage() {
156179
if (sourceLuma == null || f.lastModified() != sourceTimestamp) {
157180
BufferedImage src = ImageIO.read(f);
158181
if (src != null) {
159-
BufferedImage scaled = new BufferedImage(128, 112, BufferedImage.TYPE_INT_RGB);
160-
Graphics2D g = scaled.createGraphics();
161-
g.drawImage(src, 0, 0, 128, 112, null);
162-
g.dispose();
163-
int[] luma = new int[128 * 112];
164-
for (int y = 0; y < 112; y++) {
165-
for (int x = 0; x < 128; x++) {
166-
int rgb = scaled.getRGB(x, y);
167-
luma[y * 128 + x] = ((rgb >> 16 & 0xff) * 77 + (rgb >> 8 & 0xff) * 151 + (rgb & 0xff) * 28) >> 8;
168-
}
169-
}
170-
sourceLuma = luma;
182+
sourceLuma = toLuma(src);
171183
sourceTimestamp = f.lastModified();
172184
}
173185
}
@@ -191,6 +203,22 @@ private int[] sensorImage() {
191203
return luma;
192204
}
193205

206+
/** Scales a frame to the sensor's 128x112 and converts it to per-pixel luminance. */
207+
private static int[] toLuma(BufferedImage src) {
208+
BufferedImage scaled = new BufferedImage(128, 112, BufferedImage.TYPE_INT_RGB);
209+
Graphics2D g = scaled.createGraphics();
210+
g.drawImage(src, 0, 0, 128, 112, null);
211+
g.dispose();
212+
int[] luma = new int[128 * 112];
213+
for (int y = 0; y < 112; y++) {
214+
for (int x = 0; x < 128; x++) {
215+
int rgb = scaled.getRGB(x, y);
216+
luma[y * 128 + x] = ((rgb >> 16 & 0xff) * 77 + (rgb >> 8 & 0xff) * 151 + (rgb & 0xff) * 28) >> 8;
217+
}
218+
}
219+
return luma;
220+
}
221+
194222
@Override
195223
public void flushRam() {
196224
battery.saveRam(ram);

swing/pom.xml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,5 +58,10 @@
5858
<artifactId>core</artifactId>
5959
<version>1.7.2-SNAPSHOT</version>
6060
</dependency>
61+
<dependency>
62+
<groupId>com.github.sarxos</groupId>
63+
<artifactId>webcam-capture</artifactId>
64+
<version>0.3.12</version>
65+
</dependency>
6166
</dependencies>
6267
</project>

swing/src/main/java/eu/rekawek/coffeegb/swing/SwingMenu.kt

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ import eu.rekawek.coffeegb.controller.properties.EmulatorProperties.Key.DmgGames
2828
import eu.rekawek.coffeegb.core.GameboyType
2929
import eu.rekawek.coffeegb.core.events.EventBus
3030
import eu.rekawek.coffeegb.core.genie.AddPatches
31+
import eu.rekawek.coffeegb.core.memory.cart.type.PocketCamera
32+
import eu.rekawek.coffeegb.swing.io.WebcamCameraSource
3133
import eu.rekawek.coffeegb.core.genie.PatchFactory
3234
import eu.rekawek.coffeegb.core.sgb.SgbDisplay
3335
import eu.rekawek.coffeegb.core.sound.Sound
@@ -58,6 +60,8 @@ class SwingMenu(
5860

5961
private var pauseSupport: Boolean = false
6062

63+
private var webcamSource: WebcamCameraSource? = null
64+
6165
init {
6266
eventBus.register<SessionSnapshotSupportEvent> { snapshotSupport = it.snapshotSupport }
6367
eventBus.register<Controller.SessionPauseSupportEvent> { pauseSupport = it.enabled }
@@ -236,6 +240,30 @@ class SwingMenu(
236240
}
237241
enableWhenEmulationActive(scanBarcode)
238242

243+
val webcam = JCheckBoxMenuItem("Use webcam as camera", false)
244+
gameMenu.add(webcam)
245+
webcam.addActionListener {
246+
if (webcam.state) {
247+
val source = WebcamCameraSource.open()
248+
if (source == null) {
249+
webcam.state = false
250+
JOptionPane.showMessageDialog(
251+
window,
252+
"No webcam could be opened.",
253+
"Game Boy Camera",
254+
JOptionPane.ERROR_MESSAGE,
255+
)
256+
} else {
257+
webcamSource = source
258+
PocketCamera.setCameraSource(source)
259+
}
260+
} else {
261+
PocketCamera.setCameraSource(null)
262+
webcamSource?.close()
263+
webcamSource = null
264+
}
265+
}
266+
239267
return gameMenu
240268
}
241269

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
package eu.rekawek.coffeegb.swing.io;
2+
3+
import com.github.sarxos.webcam.Webcam;
4+
import eu.rekawek.coffeegb.core.memory.cart.type.CameraSource;
5+
import org.slf4j.Logger;
6+
import org.slf4j.LoggerFactory;
7+
8+
import java.awt.Dimension;
9+
import java.awt.image.BufferedImage;
10+
11+
/**
12+
* A live {@link CameraSource} for the Game Boy (Pocket) Camera, backed by a real webcam
13+
* through the sarxos webcam-capture library. The webcam runs in async mode: a background
14+
* thread keeps the latest frame buffered, so {@link #getFrame()} is a cheap non-blocking
15+
* read the emulator can call on every in-game capture.
16+
*/
17+
public class WebcamCameraSource implements CameraSource, AutoCloseable {
18+
19+
private static final Logger LOG = LoggerFactory.getLogger(WebcamCameraSource.class);
20+
21+
private final Webcam webcam;
22+
23+
private WebcamCameraSource(Webcam webcam) {
24+
this.webcam = webcam;
25+
}
26+
27+
/**
28+
* Opens the default webcam.
29+
*
30+
* @return the source, or {@code null} if no webcam is available or it cannot be opened
31+
*/
32+
public static WebcamCameraSource open() {
33+
try {
34+
Webcam webcam = Webcam.getDefault();
35+
if (webcam == null) {
36+
LOG.warn("No webcam detected");
37+
return null;
38+
}
39+
// the frame is downscaled to the 128x112 sensor anyway, so prefer the smallest
40+
// supported resolution that still covers it (lower latency and CPU)
41+
Dimension best = null;
42+
for (Dimension d : webcam.getViewSizes()) {
43+
if (d.width < 128 || d.height < 112) {
44+
continue;
45+
}
46+
if (best == null || (long) d.width * d.height < (long) best.width * best.height) {
47+
best = d;
48+
}
49+
}
50+
if (best != null) {
51+
webcam.setViewSize(best);
52+
}
53+
webcam.open(true);
54+
LOG.info("Opened webcam {} at {}", webcam.getName(), webcam.getViewSize());
55+
return new WebcamCameraSource(webcam);
56+
} catch (Throwable t) {
57+
LOG.warn("Failed to open the webcam", t);
58+
return null;
59+
}
60+
}
61+
62+
@Override
63+
public BufferedImage getFrame() {
64+
return webcam.isOpen() ? webcam.getImage() : null;
65+
}
66+
67+
@Override
68+
public void close() {
69+
try {
70+
webcam.close();
71+
} catch (Throwable t) {
72+
LOG.warn("Failed to close the webcam", t);
73+
}
74+
}
75+
}

0 commit comments

Comments
 (0)