├── .dockerignore ├── .eslintignore ├── .eslintrc ├── .gitignore ├── .npmrc ├── Cargo.lock ├── Cargo.toml ├── Cross.toml ├── LICENSE ├── README.md ├── docs ├── DEPLOY_INSTANCE.md ├── DEPLOY_WORKER.md └── DEVELOPER.md ├── img_hash ├── Cargo.toml ├── LICENSE-APACHE ├── LICENSE-MIT └── src │ ├── alg │ ├── blockhash.rs │ └── mod.rs │ ├── bin │ └── hash_image.rs │ ├── dct.rs │ ├── fr.rs │ ├── lib.rs │ └── traits.rs ├── migrations ├── 0000_init.sql ├── 0001_source_size.sql └── 0002_source_size_trigger.sql ├── package.json ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── proto-rs ├── Cargo.toml ├── build.rs └── src │ └── lib.rs ├── proto └── gateway.mit.proto ├── rust-toolchain.toml ├── rustfmt.toml ├── services ├── inpainting_lama_v1 │ ├── inpainting.py │ ├── main.py │ └── models.py ├── mask_refinement_v1 │ ├── main.py │ ├── models.py │ ├── text_mask_utils.py │ └── utils.py ├── models │ ├── Cargo.lock │ ├── Cargo.toml │ └── src │ │ └── lib.rs ├── ocr_ctc_v1 │ ├── alphabet-all-v5.txt │ ├── main.py │ ├── models.py │ ├── ocr.py │ └── utils.py ├── phash │ ├── Cargo.lock │ ├── Cargo.toml │ └── src │ │ └── main.rs ├── text_detection_v1 │ ├── craft_utils.py │ ├── dbnet_utils.py │ ├── detector.py │ ├── main.py │ ├── models.py │ └── utils.py ├── textline_merge_v1 │ ├── main.py │ ├── models.py │ ├── textline_merge.py │ └── utils.py └── wasm │ ├── .gitignore │ ├── Cargo.toml │ ├── rust-toolchain │ └── src │ ├── lib.rs │ └── utils.rs ├── specs └── cotrans.yaml ├── types ├── .gitignore ├── LICENSE ├── package.json ├── src │ └── index.ts └── tsconfig.json ├── uno.config.ts ├── userscript ├── .gitignore ├── .vscode │ ├── i18n-ally-custom-framework.yml │ └── settings.json ├── package.json ├── rollup.config.js ├── src │ ├── banner-nsfw.js │ ├── banner-regular.js │ ├── eHentai │ │ ├── gallery.ts │ │ ├── page.ts │ │ └── settings.ts │ ├── i18n │ │ ├── en-US.yml │ │ ├── index.ts │ │ └── zh-CN.yml │ ├── main-nsfw.ts │ ├── main-regular.ts │ ├── main.ts │ ├── misskey │ │ └── index.tsx │ ├── pixiv │ │ ├── index.tsx │ │ └── settings.tsx │ ├── settings │ │ └── index.tsx │ ├── shims.d.ts │ ├── twitter │ │ ├── index.tsx │ │ └── settings.tsx │ └── utils │ │ ├── core.ts │ │ ├── index.ts │ │ ├── storage.ts │ │ └── twind.ts ├── tsconfig.json └── vitest.config.ts ├── web ├── .gitignore ├── app.vue ├── components │ ├── footer │ │ └── index.vue │ ├── nav │ │ └── index.vue │ └── u │ │ └── Listbox.vue ├── layouts │ └── default.vue ├── nuxt.config.ts ├── package.json ├── pages │ ├── index.vue │ └── userscript.vue └── tsconfig.json ├── wk-gateway ├── .gitignore ├── buf.gen.yaml ├── build.config.ts ├── package.json ├── shims.d.ts ├── src │ ├── db.ts │ ├── index.ts │ ├── mitWorker │ │ ├── dObject.ts │ │ ├── id.ts │ │ ├── index.ts │ │ └── ttl.ts │ ├── task │ │ ├── index.ts │ │ └── upload.ts │ ├── types.ts │ └── utils │ │ ├── index.ts │ │ ├── memo.ts │ │ └── png.ts ├── tsconfig.json ├── wrangler.domitworker.toml └── wrangler.toml ├── wk-image ├── .gitignore ├── Cargo.toml ├── package.json ├── src │ └── lib.rs └── wrangler.toml └── wkr2 ├── .gitignore ├── .prettierrc ├── README.md ├── package.json ├── src └── index.ts ├── tsconfig.json └── wrangler.toml /.dockerignore: -------------------------------------------------------------------------------- 1 | local-tests 2 | *.ckpt 3 | *.pyc 4 | __pycache__ 5 | target/ 6 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | /wk-image/build/ 2 | /target/ 3 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["@antfu"], 3 | "ignorePatterns": ["/wk-gateway/src/protoGen"] 4 | } 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | 3 | local-tests 4 | *.ckpt 5 | *.pyc 6 | __pycache__ 7 | 8 | target/ 9 | node_modules 10 | 11 | /gateway/src/prisma.rs 12 | /wk-gateway/src/protoGen 13 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | shamefully-hoist=true 2 | ignore-workspace-root-check=true 3 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | resolver = "2" 3 | members = ["proto-rs", "wk-image", "img_hash"] 4 | 5 | [workspace.package] 6 | edition = "2021" 7 | rust-version = "1.71.0" 8 | 9 | [profile.release] 10 | lto = true 11 | strip = true 12 | codegen-units = 1 13 | -------------------------------------------------------------------------------- /Cross.toml: -------------------------------------------------------------------------------- 1 | [target.x86_64-unknown-linux-musl] 2 | pre-build = [ 3 | "apt-get update && apt-get install --assume-yes protobuf-compiler", 4 | ] 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Cotrans 2 | 3 | A working-in-progress collaborative online image/manga translation platform base on 4 | [manga-image-translator](https://github.com/zyddnys/manga-image-translator). 5 | 6 | ## Contributing 7 | 8 | ### Repository structure 9 | 10 | | Path | Description | 11 | | ------------ | -------------------------- | 12 | | `docs` | Documentations | 13 | | `specs` | OpenAPI specs (TODO) | 14 | | `proto` | Protobuf definitions | 15 | | `proto-rs` | Prost definitions | 16 | | `migrations` | Database migrations | 17 | | `types` | TypeScript definitions | 18 | | `wk-gateway` | Gateway worker | 19 | | `wk-image` | Image processing worker | 20 | | `img_hash` | Fork of `image_hasher` | 21 | | `wkr2` | R2 worker (private/public) | 22 | | `web` | Website | 23 | | `web-ext` | Browser extension | 24 | | `userscript` | UserScript | 25 | -------------------------------------------------------------------------------- /docs/DEPLOY_INSTANCE.md: -------------------------------------------------------------------------------- 1 | # Instance Deploy Guide 2 | 3 | This guide is exclusively designed for developers who are interested in deploying 4 | their own version of Cotrans. If your intention is to process images on your personal computer, 5 | you should instead refer to the [Translation Worker Deploy Guide](./DEPLOY_WORKER.md). 6 | 7 | ## Requirements 8 | 9 | You must have: 10 | 11 | - A Cloudflare account 12 | - with an active zone (domain) 13 | - if nameserver changes haven't been applied, wait for it first 14 | - with Workers Paid plan () 15 | - with Durable Objects activated (from the bottom of any Worker's page) 16 | - with R2 plan () 17 | - Any Linux server (to process tasks) 18 | - with Python 3.8+ installed 19 | - if equipped with Nvidia GPU, with the latest PyTorch + CUDA installed 20 | - recommended minimum 28GB RAM, or 16GB RAM with 16GB GPU VRAM 21 | - exposing to the Internet is *not* required 22 | 23 | ## Preparation 24 | 25 | Install the following tools: 26 | 27 | - Node.js 18+ () 28 | - with `pnpm` () 29 | - with `wrangler` (`pnpm i -g wrangler`) 30 | - Rust 1.68+ () 31 | 32 | ```bash 33 | # Generate a pair of ECDSA private/public key (to sign/verify JWT) 34 | # Results will be saved in `private.pem` and `public.pem` 35 | openssl ecparam -name prime256v1 -genkey -noout | tee >(openssl ec -pubout -out public.pem) | openssl pkcs8 -topk8 -nocrypt -out private.pem 36 | 37 | # Clone the repo 38 | git clone https://github.com/VoileLabs/cotrans 39 | cd cotrans 40 | 41 | # Install dependencies 42 | cargo check 43 | pnpm i 44 | 45 | # Build the project 46 | pnpm build 47 | ``` 48 | 49 | ## Deploy R2 buckets and `wkr2` 50 | 51 | Create two R2 buckets in Cloudflare dashboard: `cotrans-public`, `cotrans-private`. 52 | 53 | Bind a custom domain to the `cotrans-public` bucket. 54 | This domain will be referred as `r2.cotrans.example.com` in the following steps. 55 | 56 | Add a CORS policy, with `GET` method allowed. 57 | 58 | An example policy: 59 | 60 | ```json 61 | [ 62 | { 63 | "AllowedOrigins": [ 64 | "*" 65 | ], 66 | "AllowedMethods": [ 67 | "GET" 68 | ], 69 | "AllowedHeaders": [ 70 | "*" 71 | ], 72 | "ExposeHeaders": [ 73 | "ETag" 74 | ], 75 | "MaxAgeSeconds": 600 76 | } 77 | ] 78 | ``` 79 | 80 | Run the following commands to deploy `cotrans-wkr2-public` and `cotrans-wkr2-private`: 81 | 82 | ```bash 83 | cd wkr2 84 | wrangler deploy --keep-vars 85 | wrangler deploy --keep-vars --env private 86 | ``` 87 | 88 | Open the settings page of both workers, click "Variables", and add the following environment variables: 89 | 90 | - `JWT_PUBLIC_KEY`: the content of `public.pem` 91 | - `JWT_AUDIENCE`: `wk:r2:private` for `cotrans-wkr2-private`, `wk:r2:public` for `cotrans-wkr2-public` 92 | 93 | Scroll down to the "R2 Bucket Bindings" section, ensure a bucket is bound to `BUCKET`. 94 | If not, click "Edit variable" and add a bucket named `BUCKET`, to `cotrans-public` for `wkr2-public`, `cotrans-private` for `wkr2-private`. 95 | 96 | Open the triggers page, add a custom domain for each worker. 97 | The domains will be referred as `public.r2.wk.cotrans.example.com` and `private.r2.wk.cotrans.example.com` in the following steps. 98 | 99 | Both workers are recommended be in "Bundled" usage model. 100 | 101 | ## Deploy `wk-image` 102 | 103 | Run the following commands to deploy `wk-image`: 104 | 105 | ```bash 106 | cd wk-image 107 | wrangler deploy --keep-vars 108 | ``` 109 | 110 | Open the settings page of the worker, ensure a bucket is bound to `BUCKET_PRI`. 111 | If not, click "Edit variable" and add a bucket named `BUCKET_PRI`, to `cotrans-private`. 112 | 113 | The worker must be in "Unbound" usage model. 114 | 115 | ## Deploy `wk-gateway` 116 | 117 | Create a D1 database in Cloudflare dashboard, named `cotrans`. 118 | 119 | Edit `wk-gateway/wrangler.toml`, replace the value of `database_id` with the ID of the database. 120 | 121 | Edit `wk-gateway/src/index.ts`, put the domain for `web` inside `CORS_ORIGINS`. 122 | 123 | Run the following commands to deploy `cotrans-wk-gateway`: 124 | 125 | ```bash 126 | cd wk-gateway 127 | wrangler d1 migrations apply DB --experimental-backend 128 | wrangler deploy --keep-vars -c wrangler.domitworker.toml 129 | wrangler deploy --keep-vars 130 | ``` 131 | 132 | This should produce two workers: `cotrans-wk-gateway-domitworker` and `cotrans-wk-gateway`. 133 | 134 | Open the settings page of both workers, do the following: 135 | 136 | - Click "Variables", and add the following environment variables: 137 | - `JWT_PRIVATE_KEY`: the content of `private.pem` 138 | - `JWT_PUBLIC_KEY`: the content of `public.pem` 139 | - `MIT_WORKERS_SECRET`: a random string, used by the Linux server to authenticate itself 140 | - Try using a password generator if you don't know what to put 141 | - `WKR2_PRIVATE_BASE`: `https://private.r2.wk.cotrans.example.com` 142 | - `WKR2_PUBLIC_BASE`: `https://public.r2.wk.cotrans.example.com` 143 | - `WKR2_PUBLIC_EXPOSED_BASE`: `https://r2.cotrans.example.com` 144 | - Scroll down to the "Durable Object Bindings" section, ensure the following bindings exist: 145 | - `doMitWorker`: bind to `cotrans-wk-gateway-domitworker_DOMitWorker` 146 | - `doImage`: bind to `cotrans-wk-image_DOImage` 147 | - If not, click "Edit variable" and add the bindings. 148 | - Scroll down to the "Service Bindings" section, ensure the following bindings exist: 149 | - `wkr2_private`: bind to `cotrans-wkr2-private` 150 | - `wkr2_public`: bind to `cotrans-wkr2-public` 151 | - If not, click "Edit variable" and add the bindings. 152 | - On `cotrans-wk-gateway`, open the triggers page, add a custom domain, this will be the api domain. 153 | - The domain will be referred as `gateway.wk.cotrans.example.com` in the following steps. 154 | 155 | Both workers are recommended be in "Bundled" usage model. 156 | 157 | ## Deploy `web` 158 | 159 | Run the following commands to deploy `web`: 160 | 161 | ```bash 162 | cd web 163 | NUXT_PUBLIC_API_BASE=https://gateway.wk.cotrans.example.com NUXT_PUBLIC_WS_BASE=wss://gateway.wk.cotrans.example.com pnpm generate 164 | wrangler pages deploy .output/public 165 | ``` 166 | 167 | Bind a custom domain to the worker, this will be the website domain. 168 | 169 | ## Deploy mit workers 170 | 171 | SSH into the Linux server, follow the instructions in . 172 | 173 | Set the api keys in `.env` file for translation services you plan to use. 174 | 175 | Replace `` in the following commands with the value of `MIT_WORKERS_SECRET` in `cotrans-wk-gateway`'s environment variables. 176 | 177 | ```bash 178 | WS_SECRET= python -m manga_translator --use-cuda --mode ws --ws-url wss://gateway.wk.cotrans.example.com/mit/worker_ws 179 | ``` 180 | 181 | Congratulations! Your website should be able to translate images now. 182 | -------------------------------------------------------------------------------- /docs/DEPLOY_WORKER.md: -------------------------------------------------------------------------------- 1 | # Translation Worker Deploy Guide 2 | 3 | > TODO (not yet implemented) 4 | 5 | This guide will assist you in setting up a Translation Worker. 6 | Once set up, you can link it to your Cotrans dashboard to process your own images. 7 | -------------------------------------------------------------------------------- /docs/DEVELOPER.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/VoileLabs/cotrans/08992fa0d89e40f22477e90a361823942f2f8c86/docs/DEVELOPER.md -------------------------------------------------------------------------------- /img_hash/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | 3 | name = "image_hasher" 4 | version = "1.2.0" 5 | edition.workspace = true 6 | rust-version.workspace = true 7 | license = "MIT OR Apache-2.0" 8 | publish = false 9 | 10 | authors = [ 11 | "Rafał Mikrut ", 12 | "Austin Bonander ", 13 | ] 14 | description = "A simple library that provides perceptual hashing and difference calculation for images." 15 | documentation = "http://docs.rs/image_hasher" 16 | keywords = ["image", "hash", "perceptual", "difference"] 17 | repository = "http://github.com/qarmin/img_hash" 18 | readme = "README.md" 19 | 20 | [features] 21 | nightly = [] 22 | 23 | [dependencies] 24 | base64 = "0.21.2" 25 | image = { version = "0.24.6", default-features = false } 26 | fast_image_resize = "2.7.3" 27 | rustdct = "0.7" 28 | serde = { version = "1.0", features = ["derive"] } 29 | transpose = "0.2" 30 | 31 | [dev-dependencies] 32 | criterion = "0.5.1" 33 | rand = { version = "0.8", features = ["small_rng"] } 34 | 35 | [[bin]] 36 | name = "hash_image" 37 | -------------------------------------------------------------------------------- /img_hash/LICENSE-MIT: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015-2017 The `img_hash` Crate Developers 2 | 3 | Permission is hereby granted, free of charge, to any 4 | person obtaining a copy of this software and associated 5 | documentation files (the "Software"), to deal in the 6 | Software without restriction, including without 7 | limitation the rights to use, copy, modify, merge, 8 | publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software 10 | is furnished to do so, subject to the following 11 | conditions: 12 | 13 | The above copyright notice and this permission notice 14 | shall be included in all copies or substantial portions 15 | of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 18 | ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED 19 | TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 20 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT 21 | SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 22 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 23 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR 24 | IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 25 | DEALINGS IN THE SOFTWARE. 26 | -------------------------------------------------------------------------------- /img_hash/src/alg/blockhash.rs: -------------------------------------------------------------------------------- 1 | use std::cmp::Ordering; 2 | use std::mem; 3 | use std::ops::AddAssign; 4 | 5 | // Implementation adapted from Python version: 6 | // https://github.com/commonsmachinery/blockhash-python/blob/e8b009d/blockhash.py 7 | // Main site: http://blockhash.io 8 | use image::{GenericImageView, Pixel}; 9 | 10 | use crate::BitSet; 11 | use crate::{HashBytes, Image}; 12 | 13 | const FLOAT_EQ_MARGIN: f32 = 0.001; 14 | 15 | pub fn blockhash(img: &I, width: u32, height: u32) -> B { 16 | assert_eq!(width % 4, 0, "width must be multiple of 4"); 17 | assert_eq!(height % 4, 0, "height must be multiple of 4"); 18 | 19 | let (iwidth, iheight) = img.dimensions(); 20 | 21 | // Skip the floating point math if it's unnecessary 22 | if iwidth % width == 0 && iheight % height == 0 { 23 | blockhash_fast(img, width, height) 24 | } else { 25 | blockhash_slow(img, width, height) 26 | } 27 | } 28 | 29 | macro_rules! gen_hash { 30 | ($imgty:ty, $valty:ty, $blocks: expr, $width:expr, $block_width:expr, $block_height:expr, $eq_fn:expr) => {{ 31 | #[allow(deprecated)] // deprecated as of 0.22 32 | let channel_count = <<$imgty as GenericImageView>::Pixel as Pixel>::CHANNEL_COUNT as u32; 33 | 34 | let group_len = ($width * 4) as usize; 35 | 36 | let block_area = $block_width * $block_height; 37 | 38 | let cmp_factor = match channel_count { 39 | 3 | 4 => 255u32 as $valty * 3u32 as $valty, 40 | 2 | 1 => 255u32 as $valty, 41 | _ => panic!("Unrecognized channel count from Image: {}", channel_count), 42 | } * block_area 43 | / (2u32 as $valty); 44 | 45 | let medians: Vec<$valty> = $blocks.chunks(group_len).map(get_median).collect(); 46 | 47 | BitSet::from_bools( 48 | $blocks 49 | .chunks(group_len) 50 | .zip(medians) 51 | .flat_map(|(blocks, median)| { 52 | blocks 53 | .iter() 54 | .map(move |&block| block > median || ($eq_fn(block, median) && median > cmp_factor)) 55 | }), 56 | ) 57 | }}; 58 | } 59 | 60 | fn block_adder<'a, T: AddAssign + 'a>( 61 | blocks: &'a mut [T], 62 | width: u32, 63 | ) -> impl FnMut(u32, u32, T) + 'a { 64 | move |x, y, add| (blocks[(y as usize) * (width as usize) + (x as usize)] += add) 65 | } 66 | 67 | fn blockhash_slow(img: &I, hwidth: u32, hheight: u32) -> B { 68 | let mut blocks = vec![0f32; (hwidth * hheight) as usize]; 69 | 70 | let (iwidth, iheight) = img.dimensions(); 71 | 72 | // Block dimensions, in pixels 73 | let (block_width, block_height) = ( 74 | iwidth as f32 / hwidth as f32, 75 | iheight as f32 / hheight as f32, 76 | ); 77 | 78 | img.foreach_pixel8(|x, y, px| { 79 | let mut add_to_block = block_adder(&mut blocks, hwidth); 80 | 81 | let px_sum = sum_px(px) as f32; 82 | 83 | let (x, y) = (x as f32, y as f32); 84 | 85 | let block_x = x / block_width; 86 | let block_y = y / block_height; 87 | 88 | let x_mod = x + 1. % block_width; 89 | let y_mod = y + 1. % block_height; 90 | 91 | // terminology is mostly arbitrary as long as we're consistent 92 | // if `x` evenly divides `block_height`, this weight will be 0 93 | // so we don't double the sum as `block_top` will equal `block_bottom` 94 | let weight_left = x_mod.fract(); 95 | let weight_right = 1. - weight_left; 96 | let weight_top = y_mod.fract(); 97 | let weight_bottom = 1. - weight_top; 98 | 99 | let block_left = block_x.floor() as u32; 100 | let block_top = block_y.floor() as u32; 101 | 102 | let block_right = if x_mod.trunc() == 0. { 103 | block_x.ceil() as u32 104 | } else { 105 | block_left 106 | }; 107 | 108 | let block_bottom = if y_mod.trunc() == 0. { 109 | block_y.ceil() as u32 110 | } else { 111 | block_top 112 | }; 113 | 114 | add_to_block(block_left, block_top, px_sum * weight_left * weight_top); 115 | add_to_block( 116 | block_left, 117 | block_bottom, 118 | px_sum * weight_left * weight_bottom, 119 | ); 120 | add_to_block(block_right, block_top, px_sum * weight_right * weight_top); 121 | add_to_block( 122 | block_right, 123 | block_bottom, 124 | px_sum * weight_right * weight_bottom, 125 | ); 126 | }); 127 | 128 | gen_hash!( 129 | I, 130 | f32, 131 | blocks, 132 | hwidth, 133 | block_width, 134 | block_height, 135 | |l: f32, r: f32| (l - r).abs() < FLOAT_EQ_MARGIN 136 | ) 137 | } 138 | 139 | fn blockhash_fast(img: &I, hwidth: u32, hheight: u32) -> B { 140 | let mut blocks = vec![0u32; (hwidth * hheight) as usize]; 141 | let (iwidth, iheight) = img.dimensions(); 142 | 143 | let (block_width, block_height) = (iwidth / hwidth, iheight / hheight); 144 | 145 | img.foreach_pixel8(|x, y, px| { 146 | let mut add_to_block = block_adder(&mut blocks, hwidth); 147 | 148 | let px_sum = sum_px(px); 149 | 150 | let block_x = x / block_width; 151 | let block_y = y / block_height; 152 | 153 | add_to_block(block_x, block_y, px_sum); 154 | }); 155 | 156 | gen_hash!(I, u32, blocks, hwidth, block_width, block_height, |l, r| l 157 | == r) 158 | } 159 | 160 | #[inline(always)] 161 | fn sum_px(chans: &[u8]) -> u32 { 162 | // Branch prediction should eliminate the match after a few iterations 163 | match chans.len() { 164 | 4 => { 165 | if chans[3] == 0 { 166 | 255 * 3 167 | } else { 168 | sum_px(&chans[..3]) 169 | } 170 | } 171 | 3 => chans.iter().map(|&x| x as u32).sum(), 172 | 2 => { 173 | if chans[1] == 0 { 174 | 255 175 | } else { 176 | chans[0] as u32 177 | } 178 | } 179 | 1 => chans[0] as u32, 180 | channels => panic!("Unsupported channel count in image: {channels}"), 181 | } 182 | } 183 | 184 | fn get_median(data: &[T]) -> T { 185 | let mut scratch = data.to_owned(); 186 | let median = scratch.len() / 2; 187 | *qselect_inplace(&mut scratch, median) 188 | } 189 | 190 | const SORT_THRESH: usize = 8; 191 | 192 | fn qselect_inplace(data: &mut [T], k: usize) -> &mut T { 193 | let len = data.len(); 194 | 195 | assert!( 196 | k < len, 197 | "Called qselect_inplace with k = {k} and data length: {len}", 198 | ); 199 | 200 | if len < SORT_THRESH { 201 | data.sort_by(|left, right| left.partial_cmp(right).unwrap_or(Ordering::Less)); 202 | return &mut data[k]; 203 | } 204 | 205 | let pivot_idx = partition(data); 206 | match k.cmp(&pivot_idx) { 207 | Ordering::Less => qselect_inplace(&mut data[..pivot_idx], k), 208 | Ordering::Equal => &mut data[pivot_idx], 209 | Ordering::Greater => qselect_inplace(&mut data[pivot_idx + 1..], k - pivot_idx - 1), 210 | } 211 | } 212 | 213 | fn partition(data: &mut [T]) -> usize { 214 | let len = data.len(); 215 | 216 | let pivot_idx = { 217 | let first = (&data[0], 0); 218 | let mid = (&data[len / 2], len / 2); 219 | let last = (&data[len - 1], len - 1); 220 | 221 | median_of_3(&first, &mid, &last).1 222 | }; 223 | 224 | data.swap(pivot_idx, len - 1); 225 | 226 | let mut curr = 0; 227 | 228 | for i in 0..len - 1 { 229 | if data[i] < data[len - 1] { 230 | data.swap(i, curr); 231 | curr += 1; 232 | } 233 | } 234 | 235 | data.swap(curr, len - 1); 236 | 237 | curr 238 | } 239 | 240 | fn median_of_3(mut x: T, mut y: T, mut z: T) -> T { 241 | if x > y { 242 | mem::swap(&mut x, &mut y); 243 | } 244 | 245 | if x > z { 246 | mem::swap(&mut x, &mut z); 247 | } 248 | 249 | if x > z { 250 | mem::swap(&mut y, &mut z); 251 | } 252 | 253 | y 254 | } 255 | -------------------------------------------------------------------------------- /img_hash/src/alg/mod.rs: -------------------------------------------------------------------------------- 1 | #![allow(clippy::needless_lifetimes)] 2 | use crate::CowImage::*; 3 | use crate::HashVals::*; 4 | use crate::{BitSet, HashCtxt, Image}; 5 | 6 | use self::HashAlg::*; 7 | 8 | mod blockhash; 9 | 10 | /// Hash algorithms implemented by this crate. 11 | /// 12 | /// Implemented primarily based on the high-level descriptions on the blog Hacker Factor 13 | /// written by Dr. Neal Krawetz: http://www.hackerfactor.com/ 14 | /// 15 | /// Note that `hash_width` and `hash_height` in these docs refer to the parameters of 16 | /// [`HasherConfig::hash_size()`](struct.HasherConfig.html#method.hash_size). 17 | /// 18 | /// ### Choosing an Algorithm 19 | /// Each algorithm has different performance characteristics 20 | #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] 21 | pub enum HashAlg { 22 | /// The Mean hashing algorithm. 23 | /// 24 | /// The image is converted to grayscale, scaled down to `hash_width x hash_height`, 25 | /// the mean pixel value is taken, and then the hash bits are generated by comparing 26 | /// the pixels of the descaled image to the mean. 27 | /// 28 | /// This is the most basic hash algorithm supported, resistant only to changes in 29 | /// resolution, aspect ratio, and overall brightness. 30 | /// 31 | /// Further Reading: 32 | /// http://www.hackerfactor.com/blog/?/archives/432-Looks-Like-It.html 33 | Mean, 34 | 35 | /// The Gradient hashing algorithm. 36 | /// 37 | /// The image is converted to grayscale, scaled down to `(hash_width + 1) x hash_height`, 38 | /// and then in row-major order the pixels are compared with each other, setting bits 39 | /// in the hash for each comparison. The extra pixel is needed to have `hash_width` comparisons 40 | /// per row. 41 | /// 42 | /// This hash algorithm is as fast or faster than Mean (because it only traverses the 43 | /// hash data once) and is more resistant to changes than Mean. 44 | /// 45 | /// Further Reading: 46 | /// http://www.hackerfactor.com/blog/index.php?/archives/529-Kind-of-Like-That.html 47 | Gradient, 48 | 49 | /// The Vertical-Gradient hashing algorithm. 50 | /// 51 | /// Equivalent to [`Gradient`](#variant.Gradient) but operating on the columns of the image 52 | /// instead of the rows. 53 | VertGradient, 54 | 55 | /// The Double-Gradient hashing algorithm. 56 | /// 57 | /// An advanced version of [`Gradient`](#variant.Gradient); 58 | /// resizes the grayscaled image to `(width / 2 + 1) x (height / 2 + 1)` and compares columns 59 | /// in addition to rows. 60 | /// 61 | /// This algorithm is slightly slower than `Gradient` (resizing the image dwarfs 62 | /// the hash time in most cases) but the extra comparison direction may improve results (though 63 | /// you might want to consider increasing 64 | /// [`hash_size`](struct.HasherConfig.html#method.hash_size) 65 | /// to accommodate the extra comparisons). 66 | DoubleGradient, 67 | 68 | /// The [Blockhash.io](https://blockhash.io) algorithm. 69 | /// 70 | /// Compared to the other algorithms, this does not require any preprocessing steps and so 71 | /// may be significantly faster at the cost of some resilience. 72 | /// 73 | /// The algorithm is described in a high level here: 74 | /// https://github.com/commonsmachinery/blockhash-rfc/blob/master/main.md 75 | Blockhash, 76 | } 77 | 78 | fn next_multiple_of_2(x: u32) -> u32 { 79 | (x + 1) & !1 80 | } 81 | 82 | fn next_multiple_of_4(x: u32) -> u32 { 83 | (x + 3) & !3 84 | } 85 | 86 | impl HashAlg { 87 | pub(crate) fn hash_image(&self, ctxt: &HashCtxt, image: &I) -> B 88 | where 89 | I: Image, 90 | B: BitSet, 91 | { 92 | let post_gauss = ctxt.gauss_preproc(image); 93 | 94 | let HashCtxt { width, height, .. } = *ctxt; 95 | 96 | if *self == Blockhash { 97 | return match post_gauss { 98 | Borrowed(img) => blockhash::blockhash(img, width, height), 99 | Owned(img) => blockhash::blockhash(&img, width, height), 100 | }; 101 | } 102 | 103 | let grayscale = post_gauss.to_grayscale(); 104 | let (resize_width, resize_height) = self.resize_dimensions(width, height); 105 | 106 | let hash_vals = ctxt.calc_hash_vals(&grayscale, resize_width, resize_height); 107 | 108 | let rowstride = resize_width as usize; 109 | 110 | match (*self, hash_vals) { 111 | (Mean, Floats(ref floats)) => B::from_bools(mean_hash_f32(floats)), 112 | (Mean, Bytes(ref bytes)) => B::from_bools(mean_hash_u8(bytes)), 113 | (Gradient, Floats(ref floats)) => B::from_bools(gradient_hash(floats, rowstride)), 114 | (Gradient, Bytes(ref bytes)) => B::from_bools(gradient_hash(bytes, rowstride)), 115 | (VertGradient, Floats(ref floats)) => B::from_bools(vert_gradient_hash(floats, rowstride)), 116 | (VertGradient, Bytes(ref bytes)) => B::from_bools(vert_gradient_hash(bytes, rowstride)), 117 | (DoubleGradient, Floats(ref floats)) => { 118 | B::from_bools(double_gradient_hash(floats, rowstride)) 119 | } 120 | (DoubleGradient, Bytes(ref bytes)) => B::from_bools(double_gradient_hash(bytes, rowstride)), 121 | (Blockhash, _) => unreachable!(), 122 | } 123 | } 124 | 125 | pub(crate) fn round_hash_size(&self, width: u32, height: u32) -> (u32, u32) { 126 | match *self { 127 | DoubleGradient => (next_multiple_of_2(width), next_multiple_of_2(height)), 128 | Blockhash => (next_multiple_of_4(width), next_multiple_of_4(height)), 129 | _ => (width, height), 130 | } 131 | } 132 | 133 | pub(crate) fn resize_dimensions(&self, width: u32, height: u32) -> (u32, u32) { 134 | match *self { 135 | Mean => (width, height), 136 | Blockhash => panic!("Blockhash algorithm does not resize"), 137 | Gradient => (width + 1, height), 138 | VertGradient => (width, height + 1), 139 | DoubleGradient => (width / 2 + 1, height / 2 + 1), 140 | } 141 | } 142 | } 143 | 144 | fn mean_hash_u8<'a>(luma: &'a [u8]) -> impl Iterator + 'a { 145 | let mean = (luma.iter().map(|&l| l as u32).sum::() / luma.len() as u32) as u8; 146 | luma.iter().map(move |&x| x >= mean) 147 | } 148 | 149 | fn mean_hash_f32<'a>(luma: &'a [f32]) -> impl Iterator + 'a { 150 | let mean = luma.iter().sum::() / luma.len() as f32; 151 | luma.iter().map(move |&x| x >= mean) 152 | } 153 | 154 | /// The guts of the gradient hash separated so we can reuse them 155 | fn gradient_hash_impl(luma: I) -> impl Iterator 156 | where 157 | I: IntoIterator + Clone, 158 | ::Item: PartialOrd, 159 | { 160 | luma 161 | .clone() 162 | .into_iter() 163 | .skip(1) 164 | .zip(luma) 165 | .map(|(this, last)| last < this) 166 | } 167 | 168 | fn gradient_hash<'a, T: PartialOrd>( 169 | luma: &'a [T], 170 | rowstride: usize, 171 | ) -> impl Iterator + 'a { 172 | luma.chunks(rowstride).flat_map(gradient_hash_impl) 173 | } 174 | 175 | fn vert_gradient_hash<'a, T: PartialOrd>( 176 | luma: &'a [T], 177 | rowstride: usize, 178 | ) -> impl Iterator + 'a { 179 | (0..rowstride) 180 | .map(move |col_start| luma[col_start..].iter().step_by(rowstride)) 181 | .flat_map(gradient_hash_impl) 182 | } 183 | 184 | fn double_gradient_hash<'a, T: PartialOrd>( 185 | luma: &'a [T], 186 | rowstride: usize, 187 | ) -> impl Iterator + 'a { 188 | gradient_hash(luma, rowstride).chain(vert_gradient_hash(luma, rowstride)) 189 | } 190 | -------------------------------------------------------------------------------- /img_hash/src/bin/hash_image.rs: -------------------------------------------------------------------------------- 1 | //! Hash an image and print the Base64 value 2 | 3 | use std::env; 4 | 5 | use image_hasher::HasherConfig; 6 | 7 | fn main() -> Result<(), String> { 8 | let args = env::args().collect::>(); 9 | assert_eq!(args.len(), 2); 10 | 11 | let image = image::open(&args[1]).map_err(|e| format!("failed to open {}: {}", &args[1], e))?; 12 | 13 | let hash = HasherConfig::new() 14 | .hash_size(8, 8) 15 | .to_hasher() 16 | .hash_image(&image); 17 | 18 | let hash_str = hash 19 | .as_bytes() 20 | .iter() 21 | .map(|b| format!("{b:02x}")) 22 | .collect::(); 23 | 24 | println!("{}: {}", &args[1], hash_str); 25 | 26 | Ok(()) 27 | } 28 | -------------------------------------------------------------------------------- /img_hash/src/dct.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use rustdct::{DctPlanner, TransformType2And3}; 4 | use transpose::transpose_inplace; 5 | 6 | pub const SIZE_MULTIPLIER: u32 = 2; 7 | pub const SIZE_MULTIPLIER_U: usize = SIZE_MULTIPLIER as usize; 8 | 9 | pub struct DctCtxt { 10 | row_dct: Arc>, 11 | col_dct: Arc>, 12 | width: usize, 13 | height: usize, 14 | } 15 | 16 | impl DctCtxt { 17 | pub fn new(width: u32, height: u32) -> Self { 18 | let mut planner = DctPlanner::new(); 19 | let width = width as usize * SIZE_MULTIPLIER_U; 20 | let height = height as usize * SIZE_MULTIPLIER_U; 21 | 22 | DctCtxt { 23 | row_dct: planner.plan_dct2(width), 24 | col_dct: planner.plan_dct2(height), 25 | width, 26 | height, 27 | } 28 | } 29 | 30 | pub fn width(&self) -> u32 { 31 | self.width as u32 32 | } 33 | 34 | pub fn height(&self) -> u32 { 35 | self.height as u32 36 | } 37 | 38 | /// Perform a 2D DCT on a 1D-packed vector with a given `width x height`. 39 | /// 40 | /// Assumes `packed_2d` is double-length for scratch space. Returns the vector truncated to 41 | /// `width * height`. 42 | /// 43 | /// ### Panics 44 | /// If `self.width * self.height * 2 != packed_2d.len()` 45 | pub fn dct_2d(&self, mut packed_2d: Vec) -> Vec { 46 | let Self { 47 | ref row_dct, 48 | ref col_dct, 49 | width, 50 | height, 51 | } = *self; 52 | 53 | let trunc_len = width * height; 54 | assert_eq!(trunc_len + self.required_scratch(), packed_2d.len()); 55 | 56 | { 57 | let (packed_2d, scratch) = packed_2d.split_at_mut(trunc_len); 58 | 59 | for row_in in packed_2d.chunks_mut(width) { 60 | row_dct.process_dct2_with_scratch(row_in, scratch); 61 | } 62 | 63 | transpose_inplace( 64 | packed_2d, 65 | &mut scratch[..std::cmp::max(width, height)], 66 | width, 67 | height, 68 | ); 69 | 70 | for row_in in packed_2d.chunks_mut(height) { 71 | col_dct.process_dct2_with_scratch(row_in, scratch); 72 | } 73 | 74 | transpose_inplace( 75 | packed_2d, 76 | &mut scratch[..std::cmp::max(width, height)], 77 | width, 78 | height, 79 | ); 80 | } 81 | 82 | packed_2d.truncate(trunc_len); 83 | packed_2d 84 | } 85 | 86 | pub fn crop_2d(&self, packed: Vec) -> Vec { 87 | crop_2d_dct(packed, self.width) 88 | } 89 | 90 | pub fn required_scratch(&self) -> usize { 91 | let transpose_scratch = std::cmp::max(self.width, self.height); 92 | let dct_scratch = std::cmp::max( 93 | self.row_dct.get_scratch_len(), 94 | self.col_dct.get_scratch_len(), 95 | ); 96 | std::cmp::max(transpose_scratch, dct_scratch) 97 | } 98 | } 99 | 100 | /// Crop the values off a 1D-packed 2D DCT. 101 | /// 102 | /// Returns `packed` truncated to the premultiplied size, as determined by `rowstride` 103 | /// 104 | /// Generic for easier testing 105 | fn crop_2d_dct(mut packed: Vec, rowstride: usize) -> Vec { 106 | // assert that the rowstride was previously multiplied by SIZE_MULTIPLIER 107 | assert_eq!(rowstride % SIZE_MULTIPLIER_U, 0); 108 | assert!( 109 | rowstride / SIZE_MULTIPLIER_U > 0, 110 | "rowstride cannot be cropped: {rowstride}", 111 | ); 112 | 113 | let new_rowstride = rowstride / SIZE_MULTIPLIER_U; 114 | 115 | for new_row in 0..packed.len() / (rowstride * SIZE_MULTIPLIER_U) { 116 | let (dest, src) = packed.split_at_mut(new_row * new_rowstride + rowstride); 117 | let dest_start = dest.len() - new_rowstride; 118 | let src_start = new_rowstride * new_row; 119 | let src_end = src_start + new_rowstride; 120 | dest[dest_start..].copy_from_slice(&src[src_start..src_end]); 121 | } 122 | 123 | let new_len = packed.len() / (SIZE_MULTIPLIER_U * SIZE_MULTIPLIER_U); 124 | packed.truncate(new_len); 125 | 126 | packed 127 | } 128 | 129 | #[test] 130 | fn test_crop_2d_dct() { 131 | let packed: Vec = (0..64).collect(); 132 | assert_eq!( 133 | crop_2d_dct(packed.clone(), 8), 134 | [ 135 | 0, 1, 2, 3, // 4, 5, 6, 7 136 | 8, 9, 10, 11, // 12, 13, 14, 15 137 | 16, 17, 18, 19, // 20, 21, 22, 23, 138 | 24, 25, 26, 27, // 28, 29, 30, 31, 139 | // 32 .. 64 140 | ] 141 | ); 142 | } 143 | 144 | #[test] 145 | fn test_transpose() {} 146 | -------------------------------------------------------------------------------- /img_hash/src/fr.rs: -------------------------------------------------------------------------------- 1 | use std::num::NonZeroU32; 2 | 3 | use fast_image_resize as fr; 4 | pub use fr::FilterType; 5 | use image::GrayImage; 6 | 7 | pub fn resize_gray(image: &GrayImage, width: u32, height: u32, filter: FilterType) -> GrayImage { 8 | let src_view: fr::ImageView<'_, fr::pixels::U8> = fr::ImageView::from_buffer( 9 | NonZeroU32::new(image.width()).unwrap(), 10 | NonZeroU32::new(image.height()).unwrap(), 11 | image.as_raw(), 12 | ) 13 | .unwrap(); 14 | let src_view = fr::DynamicImageView::from(src_view); 15 | 16 | let mut dst_img = fr::Image::new( 17 | NonZeroU32::new(width).unwrap(), 18 | NonZeroU32::new(height).unwrap(), 19 | fr::PixelType::U8, 20 | ); 21 | 22 | let mut dst_view = dst_img.view_mut(); 23 | 24 | let mut resizer = fr::Resizer::new(fr::ResizeAlg::Convolution(filter)); 25 | resizer.resize(&src_view, &mut dst_view).unwrap(); 26 | 27 | image::GrayImage::from_vec( 28 | dst_img.width().get(), 29 | dst_img.height().get(), 30 | dst_img.into_vec(), 31 | ) 32 | .unwrap() 33 | } 34 | -------------------------------------------------------------------------------- /img_hash/src/traits.rs: -------------------------------------------------------------------------------- 1 | use std::borrow::Cow; 2 | use std::ops; 3 | 4 | use image::{imageops, DynamicImage, GenericImageView, GrayImage, ImageBuffer, Pixel}; 5 | 6 | /// Interface for types used for storing hash data. 7 | /// 8 | /// This is implemented for `Vec`, `Box<[u8]>` and arrays that are multiples/combinations of 9 | /// useful x86 bytewise SIMD register widths (64, 128, 256, 512 bits). 10 | /// 11 | /// Please feel free to open a pull request [on Github](https://github.com/qarmin/img_hash) 12 | /// if you need this implemented for a different array size. 13 | pub trait HashBytes { 14 | /// Construct this type from an iterator of bytes. 15 | /// 16 | /// If this type has a finite capacity (i.e. an array) then it can ignore extra data 17 | /// (the hash API will not create a hash larger than this type can contain). Unused capacity 18 | /// **must** be zeroed. 19 | fn from_iter>(iter: I) -> Self 20 | where 21 | Self: Sized; 22 | 23 | /// Return the maximum capacity of this type, in bits. 24 | /// 25 | /// If this type has an arbitrary/theoretically infinite capacity, return `usize::max_value()`. 26 | fn max_bits() -> usize; 27 | 28 | /// Get the hash bytes as a slice. 29 | fn as_slice(&self) -> &[u8]; 30 | } 31 | 32 | impl HashBytes for Box<[u8]> { 33 | fn from_iter>(iter: I) -> Self { 34 | // stable in 1.32, effectively the same thing 35 | // iter.collect() 36 | iter.collect::>().into_boxed_slice() 37 | } 38 | 39 | fn max_bits() -> usize { 40 | usize::MAX 41 | } 42 | 43 | fn as_slice(&self) -> &[u8] { 44 | self 45 | } 46 | } 47 | 48 | impl HashBytes for Vec { 49 | fn from_iter>(iter: I) -> Self { 50 | iter.collect() 51 | } 52 | 53 | fn max_bits() -> usize { 54 | usize::MAX 55 | } 56 | 57 | fn as_slice(&self) -> &[u8] { 58 | self 59 | } 60 | } 61 | 62 | macro_rules! hash_bytes_array { 63 | ($($n:expr),*) => {$( 64 | impl HashBytes for [u8; $n] { 65 | fn from_iter>(mut iter: I) -> Self { 66 | // optimizer should eliminate this zeroing 67 | let mut out = [0; $n]; 68 | 69 | for (src, dest) in iter.by_ref().zip(out.as_mut()) { 70 | *dest = src; 71 | } 72 | 73 | out 74 | } 75 | 76 | fn max_bits() -> usize { 77 | $n * 8 78 | } 79 | 80 | fn as_slice(&self) -> &[u8] { self } 81 | } 82 | )*} 83 | } 84 | 85 | hash_bytes_array!(8, 16, 24, 32, 40, 48, 56, 64); 86 | 87 | struct BoolsToBytes { 88 | iter: I, 89 | } 90 | 91 | impl Iterator for BoolsToBytes 92 | where 93 | I: Iterator, 94 | { 95 | type Item = u8; 96 | 97 | fn next(&mut self) -> Option<::Item> { 98 | // starts at the LSB and works up 99 | self 100 | .iter 101 | .by_ref() 102 | .take(8) 103 | .enumerate() 104 | .fold(None, |accum, (n, val)| { 105 | accum.or(Some(0)).map(|accum| accum | ((val as u8) << n)) 106 | }) 107 | } 108 | 109 | fn size_hint(&self) -> (usize, Option) { 110 | let (lower, upper) = self.iter.size_hint(); 111 | ( 112 | lower / 8, 113 | // if the upper bound doesn't evenly divide by `8` then we will yield an extra item 114 | upper.map(|upper| { 115 | if upper % 8 == 0 { 116 | upper / 8 117 | } else { 118 | upper / 8 + 1 119 | } 120 | }), 121 | ) 122 | } 123 | } 124 | 125 | pub(crate) trait BitSet: HashBytes { 126 | fn from_bools>(iter: I) -> Self 127 | where 128 | Self: Sized, 129 | { 130 | Self::from_iter(BoolsToBytes { iter }) 131 | } 132 | 133 | fn hamming(&self, other: &Self) -> u32 { 134 | self 135 | .as_slice() 136 | .iter() 137 | .zip(other.as_slice()) 138 | .map(|(l, r)| (l ^ r).count_ones()) 139 | .sum() 140 | } 141 | } 142 | 143 | impl BitSet for T {} 144 | 145 | /// Shorthand trait bound for APIs in this crate. 146 | /// 147 | /// Currently only implemented for the types provided by `image` with 8-bit channels. 148 | pub trait Image: GenericImageView + 'static { 149 | /// The equivalent `ImageBuffer` type for this container. 150 | type Buf: Image + DiffImage; 151 | 152 | /// Grayscale the image, reducing to 8 bit depth and dropping the alpha channel. 153 | fn to_grayscale(&self) -> Cow; 154 | 155 | /// Blur the image with the given `Gaussian` sigma. 156 | fn blur(&self, sigma: f32) -> Self::Buf; 157 | 158 | /// Iterate over the image, passing each pixel's coordinates and values in `u8` to the closure. 159 | /// 160 | /// The iteration order is unspecified but each pixel **must** be visited exactly _once_. 161 | /// 162 | /// If the pixel's channels are wider than 8 bits then the values should be scaled to 163 | /// `[0, 255]`, not truncated. 164 | /// 165 | /// ### Note 166 | /// If the pixel data length is 2 or 4, the last index is assumed to be the alpha channel. 167 | /// A pixel data length outside of `[1, 4]` will cause a panic. 168 | fn foreach_pixel8(&self, foreach: F) 169 | where 170 | F: FnMut(u32, u32, &[u8]); 171 | } 172 | 173 | /// Image types that can be diffed. 174 | pub trait DiffImage { 175 | /// Subtract the pixel values of `other` from `self` in-place. 176 | fn diff_inplace(&mut self, other: &Self); 177 | } 178 | 179 | #[cfg(not(feature = "nightly"))] 180 | impl Image for ImageBuffer 181 | where 182 | P: Pixel, 183 | C: ops::Deref, 184 | { 185 | type Buf = ImageBuffer>; 186 | 187 | fn to_grayscale(&self) -> Cow { 188 | Cow::Owned(imageops::grayscale(self)) 189 | } 190 | 191 | fn blur(&self, sigma: f32) -> Self::Buf { 192 | imageops::blur(self, sigma) 193 | } 194 | 195 | fn foreach_pixel8(&self, mut foreach: F) 196 | where 197 | F: FnMut(u32, u32, &[u8]), 198 | { 199 | self 200 | .enumerate_pixels() 201 | .for_each(|(x, y, px)| foreach(x, y, px.channels())); 202 | } 203 | } 204 | 205 | #[cfg(feature = "nightly")] 206 | impl Image for ImageBuffer 207 | where 208 | P: Pixel, 209 | C: ops::Deref, 210 | { 211 | type Buf = ImageBuffer>; 212 | 213 | default fn to_grayscale(&self) -> Cow { 214 | Cow::Owned(imageops::grayscale(self)) 215 | } 216 | 217 | default fn blur(&self, sigma: f32) -> Self::Buf { 218 | imageops::blur(self, sigma) 219 | } 220 | 221 | default fn foreach_pixel8(&self, mut foreach: F) 222 | where 223 | F: FnMut(u32, u32, &[u8]), 224 | { 225 | self 226 | .enumerate_pixels() 227 | .for_each(|(x, y, px)| foreach(x, y, px.channels())) 228 | } 229 | } 230 | 231 | impl DiffImage for ImageBuffer> 232 | where 233 | P: Pixel, 234 | { 235 | fn diff_inplace(&mut self, other: &Self) { 236 | self.iter_mut().zip(other.iter()).for_each(|(l, r)| { 237 | *l = l.wrapping_sub(*r); 238 | }); 239 | } 240 | } 241 | 242 | impl Image for DynamicImage { 243 | type Buf = image::RgbaImage; 244 | 245 | fn to_grayscale(&self) -> Cow { 246 | self 247 | .as_luma8() 248 | .map_or_else(|| Cow::Owned(self.to_luma8()), Cow::Borrowed) 249 | } 250 | 251 | fn blur(&self, sigma: f32) -> Self::Buf { 252 | imageops::blur(self, sigma) 253 | } 254 | 255 | fn foreach_pixel8(&self, mut foreach: F) 256 | where 257 | F: FnMut(u32, u32, &[u8]), 258 | { 259 | self 260 | .pixels() 261 | .for_each(|(x, y, px)| foreach(x, y, px.channels())); 262 | } 263 | } 264 | 265 | #[cfg(feature = "nightly")] 266 | impl Image for GrayImage { 267 | // type Buf = GrayImage; 268 | 269 | // Avoids copying 270 | fn to_grayscale(&self) -> Cow { 271 | Cow::Borrowed(self) 272 | } 273 | } 274 | 275 | #[test] 276 | fn test_bools_to_bytes() { 277 | let bools = (0..16).map(|x| x & 1 == 0); 278 | let bytes = Vec::from_bools(bools.clone()); 279 | assert_eq!(*bytes, [0b01010101; 2]); 280 | 281 | let bools_to_bytes = BoolsToBytes { iter: bools }; 282 | assert_eq!(bools_to_bytes.size_hint(), (2, Some(2))); 283 | } 284 | -------------------------------------------------------------------------------- /migrations/0000_init.sql: -------------------------------------------------------------------------------- 1 | -- Migration number: 0000 2023-07-09T09:49:35.518Z 2 | 3 | CREATE TABLE IF NOT EXISTS source_image ( 4 | id TEXT PRIMARY KEY, 5 | hash TEXT NOT NULL, 6 | file TEXT NOT NULL UNIQUE, 7 | width INTEGER NOT NULL, 8 | height INTEGER NOT NULL, 9 | dummy INTEGER NOT NULL DEFAULT 0, 10 | created_at DATETIME DEFAULT CURRENT_TIMESTAMP, 11 | updated_at DATETIME DEFAULT CURRENT_TIMESTAMP 12 | ); 13 | 14 | CREATE TRIGGER update_source_updated_at 15 | AFTER UPDATE OF id, hash, file, width, height ON source_image 16 | FOR EACH ROW 17 | BEGIN 18 | UPDATE source_image SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id; 19 | END; 20 | 21 | CREATE TABLE IF NOT EXISTS task ( 22 | id TEXT PRIMARY_KEY, 23 | source_image_id TEXT NOT NULL, 24 | target_language INTEGER NOT NULL, 25 | detector INTEGER NOT NULL, 26 | direction INTEGER NOT NULL, 27 | translator INTEGER NOT NULL, 28 | size INTEGER NOT NULL, 29 | state INTEGER NOT NULL DEFAULT 1, 30 | last_attempted_at DATETIME, 31 | worker_revision INTEGER NOT NULL DEFAULT 0, 32 | failed_count INTEGER NOT NULL DEFAULT 0, 33 | translation_mask TEXT, 34 | dummy INTEGER NOT NULL DEFAULT 0, 35 | created_at DATETIME DEFAULT CURRENT_TIMESTAMP, 36 | updated_at DATETIME DEFAULT CURRENT_TIMESTAMP 37 | ); 38 | 39 | CREATE TRIGGER update_task_updated_at 40 | AFTER UPDATE OF id, source_image_id, target_language, detector, direction, translator, size, state, last_attempted_at, worker_revision, failed_count, translation_mask ON task 41 | FOR EACH ROW 42 | BEGIN 43 | UPDATE task SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id; 44 | END; 45 | 46 | CREATE UNIQUE INDEX ix_task_source_params_revision ON task (source_image_id, target_language, detector, direction, translator, size, worker_revision); 47 | -------------------------------------------------------------------------------- /migrations/0001_source_size.sql: -------------------------------------------------------------------------------- 1 | -- Migration number: 0001 2023-07-14T15:12:56.498Z 2 | 3 | ALTER TABLE source_image ADD COLUMN size INTEGER NOT NULL; 4 | -------------------------------------------------------------------------------- /migrations/0002_source_size_trigger.sql: -------------------------------------------------------------------------------- 1 | -- Migration number: 0002 2023-07-14T20:52:24.994Z 2 | 3 | DROP TRIGGER IF EXISTS update_source_updated_at; 4 | 5 | CREATE TRIGGER update_source_updated_at 6 | AFTER UPDATE OF id, hash, file, size, width, height ON source_image 7 | FOR EACH ROW 8 | BEGIN 9 | UPDATE source_image SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id; 10 | END; 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "scripts": { 4 | "lint": "eslint .", 5 | "build": "pnpm run -r build" 6 | }, 7 | "devDependencies": { 8 | "@antfu/eslint-config": "^0.39.8", 9 | "eslint": "^8.45.0", 10 | "typescript": "^5.1.6" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - web 3 | - web-ext 4 | - userscript 5 | - wk-gateway 6 | - wkr2 7 | - types 8 | -------------------------------------------------------------------------------- /proto-rs/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "cotrans-proto-rs" 3 | version = "0.1.5" 4 | edition.workspace = true 5 | rust-version.workspace = true 6 | publish = false 7 | 8 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 9 | 10 | [dependencies] 11 | prost = "0.11.9" 12 | prost-types = "0.11.9" 13 | 14 | [build-dependencies] 15 | prost-build = "0.11.9" 16 | -------------------------------------------------------------------------------- /proto-rs/build.rs: -------------------------------------------------------------------------------- 1 | use std::io::Result; 2 | fn main() -> Result<()> { 3 | prost_build::compile_protos(&["../proto/gateway.mit.proto"], &["../proto"])?; 4 | Ok(()) 5 | } 6 | -------------------------------------------------------------------------------- /proto-rs/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod gateway { 2 | pub mod mit { 3 | include!(concat!(env!("OUT_DIR"), "/gateway.mit.rs")); 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /proto/gateway.mit.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package gateway.mit; 4 | 5 | message NewTask { 6 | string id = 1; 7 | 8 | string source_image = 2; 9 | 10 | string target_language = 3; 11 | string detector = 4; 12 | string direction = 5; 13 | string translator = 6; 14 | string size = 7; 15 | 16 | string translation_mask = 8; 17 | } 18 | 19 | message Status { 20 | string id = 1; 21 | string status = 2; 22 | } 23 | 24 | message FinishTask { 25 | string id = 1; 26 | bool success = 2; 27 | bool has_translation_mask = 3; 28 | } 29 | 30 | message WebSocketMessage { 31 | oneof message { 32 | NewTask new_task = 1; 33 | Status status = 2; 34 | FinishTask finish_task = 3; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /rust-toolchain.toml: -------------------------------------------------------------------------------- 1 | [toolchain] 2 | channel = "nightly" 3 | -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | tab_spaces = 2 2 | -------------------------------------------------------------------------------- /services/inpainting_lama_v1/main.py: -------------------------------------------------------------------------------- 1 | 2 | from typing import Any 3 | from models import * 4 | from inpainting import load_model, run_inpainting 5 | 6 | import numpy as np 7 | from PIL import Image 8 | import io 9 | import cv2 10 | import secrets 11 | 12 | from fastapi import FastAPI, Form, File, UploadFile, Response 13 | from fastapi.responses import StreamingResponse 14 | 15 | app = FastAPI() 16 | 17 | class JsonAndImageResponse(Response) : 18 | def render(self, content: Any) -> bytes: 19 | boundary = secrets.token_bytes(16).hex() 20 | json_data: dict = content["json"] 21 | img_data: Image.Image = content["img"] 22 | img_type: str = content["img-type"] 23 | self.media_type = f"multipart/related; boundary={boundary}; start=\"jsonData\";" 24 | json_content = json.dumps(json_data) 25 | ans = io.BytesIO() 26 | ans.write(f"--{boundary}\r\n".encode('utf-8')) 27 | ans.write(b"Content-Type: application/json\r\nContent-ID: jsonData\r\n\r\n") 28 | ans.write(json_content.encode('utf-8')) 29 | ans.write(f"\r\n--{boundary}\r\n".encode('utf-8')) 30 | ans.write(f"Content-Type: image/{img_type}\r\nContent-ID: imageData\r\n\r\n".encode('utf-8')) 31 | img_data.save(ans, format = 'PNG') 32 | ans.write(f"\r\n--{boundary}--".encode('utf-8')) 33 | return ans.getvalue() 34 | 35 | @app.post("/v1/inpaint") 36 | async def inpaint(config: V1InapintingLamaRequest = Form(), image: UploadFile = File(), mask: UploadFile = File()) : 37 | img = Image.open(image.file) 38 | img_np = np.asarray(img) 39 | mask = Image.open(mask.file) 40 | mask_np = np.asarray(mask) 41 | if image.shape[:2] != mask.shape[:2] : 42 | raise ValueError(f'Image size (={image.shape[:2]}) must be the same as mask size (={mask.shape[:2]})') 43 | if len(mask_np.shape) != 2 : 44 | if mask_np.shape[-1] == 3 : 45 | mask_np = mask_np[:, :, 0] 46 | inpainted_img_np, version = run_inpainting(app, config, img_np, mask_np) 47 | resp = V1InapintingLamaResponse(version = version) 48 | return JsonAndImageResponse({"json": resp.dict(), "img": Image.fromarray(inpainted_img_np), "img-type": "png"}) 49 | 50 | @app.on_event("startup") 51 | async def startup_event() : 52 | load_model(app) 53 | -------------------------------------------------------------------------------- /services/inpainting_lama_v1/models.py: -------------------------------------------------------------------------------- 1 | 2 | from typing import Optional 3 | from pydantic import BaseModel, ValidationError, validator 4 | 5 | import json 6 | 7 | class V1InapintingLamaRequest(BaseModel) : 8 | use_poisson_blending: bool = False 9 | cuda: bool = False 10 | inpainting_size: int = 1024 11 | 12 | @classmethod 13 | def __get_validators__(cls): 14 | yield cls.validate_to_json 15 | 16 | @classmethod 17 | def validate_to_json(cls, value): 18 | if isinstance(value, str): 19 | return cls(**json.loads(value)) 20 | return value 21 | 22 | @validator("inpainting_size") 23 | def divisible_by_pad_size(cls, v) : 24 | pad_size = 8 25 | if v % pad_size != 0 : 26 | raise ValueError(f'inpainting_size(={v}) must be divisible by {pad_size}') 27 | return v 28 | 29 | class V1InapintingLamaResponse(BaseModel) : 30 | version: str 31 | 32 | @classmethod 33 | def __get_validators__(cls): 34 | yield cls.validate_to_json 35 | 36 | @classmethod 37 | def validate_to_json(cls, value): 38 | if isinstance(value, str): 39 | return cls(**json.loads(value)) 40 | return value 41 | -------------------------------------------------------------------------------- /services/mask_refinement_v1/main.py: -------------------------------------------------------------------------------- 1 | 2 | from typing import Any 3 | from models import * 4 | from text_mask_utils import run_refine_mask 5 | 6 | import numpy as np 7 | from PIL import Image 8 | import io 9 | import cv2 10 | import secrets 11 | 12 | from fastapi import FastAPI, Form, File, UploadFile, Response 13 | app = FastAPI() 14 | 15 | class JsonAndImageResponse(Response) : 16 | def render(self, content: Any) -> bytes: 17 | boundary = secrets.token_bytes(16).hex() 18 | json_data: dict = content["json"] 19 | img_data: Image.Image = content["img"] 20 | img_type: str = content["img-type"] 21 | self.media_type = f"multipart/related; boundary={boundary}; start=\"jsonData\";" 22 | json_content = json.dumps(json_data) 23 | ans = io.BytesIO() 24 | ans.write(f"--{boundary}\r\n".encode('utf-8')) 25 | ans.write(b"Content-Type: application/json\r\nContent-ID: jsonData\r\n\r\n") 26 | ans.write(json_content.encode('utf-8')) 27 | ans.write(f"\r\n--{boundary}\r\n".encode('utf-8')) 28 | ans.write(f"Content-Type: image/{img_type}\r\nContent-ID: imageData\r\n\r\n".encode('utf-8')) 29 | img_data.save(ans, format = 'PNG') 30 | ans.write(f"\r\n--{boundary}--".encode('utf-8')) 31 | return ans.getvalue() 32 | 33 | @app.post("/v1/refine") 34 | async def refine(config: V1MaskRefinementRequest = Form(), image: UploadFile = File(), mask: UploadFile = File()) : 35 | img = Image.open(image.file) 36 | img_np = np.asarray(img) 37 | mask = Image.open(mask.file) 38 | mask_np = np.asarray(mask) 39 | if img_np.shape[:2] != img_np.shape[:2] : 40 | raise ValueError(f'Image size (={img_np.shape[:2]}) must be the same as mask size (={img_np.shape[:2]})') 41 | if len(mask_np.shape) != 2 : 42 | if mask_np.shape[-1] == 3 : 43 | mask_np = mask_np[:, :, 0] 44 | textlines = [Quadrilateral.from_tref(t, img.width, img.height) for t in config.textlines] 45 | mask_np, version = run_refine_mask(img_np, mask_np, textlines, config.method) 46 | resp = V1MaskRefinementResponse(version = version) 47 | return JsonAndImageResponse({"json": resp.dict(exclude_none = True), "img": Image.fromarray(mask_np), "img-type": "png"}) 48 | -------------------------------------------------------------------------------- /services/mask_refinement_v1/models.py: -------------------------------------------------------------------------------- 1 | 2 | from __future__ import annotations 3 | from typing import List, Optional, Tuple 4 | from pydantic import BaseModel 5 | 6 | import json 7 | 8 | class TextRegionExchangeFormat(BaseModel) : 9 | fmt: str 10 | coords: str 11 | fg: Optional[Tuple[int, int, int]] 12 | bg: Optional[Tuple[int, int, int]] 13 | text: Optional[str] 14 | prob: Optional[float] = 0 15 | direction: Optional[str] 16 | lines: List[TextRegionExchangeFormat] = [] 17 | 18 | from utils import Quadrilateral 19 | 20 | TextRegionExchangeFormat.update_forward_refs() 21 | 22 | class V1MaskRefinementRequest(BaseModel) : 23 | method: str = 'fit_text' 24 | textlines: List[TextRegionExchangeFormat] 25 | 26 | @classmethod 27 | def __get_validators__(cls): 28 | yield cls.validate_to_json 29 | 30 | @classmethod 31 | def validate_to_json(cls, value): 32 | if isinstance(value, str): 33 | return cls(**json.loads(value)) 34 | return value 35 | 36 | class V1MaskRefinementResponse(BaseModel) : 37 | version: str 38 | 39 | @classmethod 40 | def __get_validators__(cls): 41 | yield cls.validate_to_json 42 | 43 | @classmethod 44 | def validate_to_json(cls, value): 45 | if isinstance(value, str): 46 | return cls(**json.loads(value)) 47 | return value 48 | -------------------------------------------------------------------------------- /services/mask_refinement_v1/text_mask_utils.py: -------------------------------------------------------------------------------- 1 | 2 | from typing import Tuple, List 3 | import numpy as np 4 | import cv2 5 | import math 6 | 7 | from tqdm import tqdm 8 | from sklearn.mixture import BayesianGaussianMixture 9 | from functools import reduce 10 | from collections import defaultdict 11 | from scipy.optimize import linear_sum_assignment 12 | 13 | COLOR_RANGE_SIGMA = 1.5 # how many stddev away is considered the same color 14 | 15 | def save_rgb(fn, img) : 16 | if len(img.shape) == 3 and img.shape[2] == 3 : 17 | cv2.imwrite(fn, cv2.cvtColor(img, cv2.COLOR_RGB2BGR)) 18 | else : 19 | cv2.imwrite(fn, img) 20 | 21 | def area(x1, y1, w1, h1, x2, y2, w2, h2): # returns None if rectangles don't intersect 22 | x_overlap = max(0, min(x1 + w1, x2 + w2) - max(x1, x2)) 23 | y_overlap = max(0, min(y1 + h1, y2 + h2) - max(y1, y2)) 24 | return x_overlap * y_overlap 25 | 26 | def dist(x1, y1, x2, y2) : 27 | return math.sqrt((x1 - x2) * (x1 - x2) + (y1 - y2) * (y1 - y2)) 28 | 29 | def rect_distance(x1, y1, x1b, y1b, x2, y2, x2b, y2b): 30 | left = x2b < x1 31 | right = x1b < x2 32 | bottom = y2b < y1 33 | top = y1b < y2 34 | if top and left: 35 | return dist(x1, y1b, x2b, y2) 36 | elif left and bottom: 37 | return dist(x1, y1, x2b, y2b) 38 | elif bottom and right: 39 | return dist(x1b, y1, x2, y2b) 40 | elif right and top: 41 | return dist(x1b, y1b, x2, y2) 42 | elif left: 43 | return x1 - x2b 44 | elif right: 45 | return x2 - x1b 46 | elif bottom: 47 | return y1 - y2b 48 | elif top: 49 | return y2 - y1b 50 | else: # rectangles intersect 51 | return 0 52 | 53 | def filter_masks(mask_img: np.ndarray, text_lines: List[Tuple[int, int, int, int]], keep_threshold = 1e-2) : 54 | mask_img = mask_img.copy() 55 | for (x, y, w, h) in text_lines : 56 | cv2.rectangle(mask_img, (x, y), (x + w, y + h), (0), 1) 57 | if len(text_lines) == 0 : 58 | return [], [] 59 | num_labels, labels, stats, centroids = cv2.connectedComponentsWithStats(mask_img) 60 | 61 | cc2textline_assignment = [] 62 | result = [] 63 | M = len(text_lines) 64 | ratio_mat = np.zeros(shape = (num_labels, M), dtype = np.float32) 65 | dist_mat = np.zeros(shape = (num_labels, M), dtype = np.float32) 66 | for i in range(1, num_labels) : 67 | if stats[i, cv2.CC_STAT_AREA] <= 9 : 68 | continue # skip area too small 69 | cc = np.zeros_like(mask_img) 70 | cc[labels == i] = 255 71 | x1, y1, w1, h1 = cv2.boundingRect(cc) 72 | area1 = w1 * h1 73 | for j in range(M) : 74 | x2, y2, w2, h2 = text_lines[j] 75 | area2 = w2 * h2 76 | overlapping_area = area(x1, y1, w1, h1, x2, y2, w2, h2) 77 | ratio_mat[i, j] = overlapping_area / min(area1, area2) 78 | dist_mat[i, j] = rect_distance(x1, y1, x1 + w1, y1 + h1, x2, y2, x2 + w2, y2 + h2) 79 | j = np.argmax(ratio_mat[i]) 80 | unit = min([h1, w1, h2, w2]) 81 | if ratio_mat[i, j] > keep_threshold : 82 | cc2textline_assignment.append(j) 83 | result.append(np.copy(cc)) 84 | else : 85 | j = np.argmin(dist_mat[i]) 86 | if dist_mat[i, j] < 0.5 * unit : 87 | cc2textline_assignment.append(j) 88 | result.append(np.copy(cc)) 89 | else : 90 | # discard 91 | pass 92 | return result, cc2textline_assignment 93 | 94 | from pydensecrf.utils import compute_unary, unary_from_softmax 95 | import pydensecrf.densecrf as dcrf 96 | 97 | def refine_mask(rgbim, rawmask) : 98 | if len(rawmask.shape) == 2 : 99 | rawmask = rawmask[:, :, None] 100 | mask_softmax = np.concatenate([cv2.bitwise_not(rawmask)[:, :, None], rawmask], axis=2) 101 | mask_softmax = mask_softmax.astype(np.float32) / 255.0 102 | n_classes = 2 103 | feat_first = mask_softmax.transpose((2, 0, 1)).reshape((n_classes,-1)) 104 | unary = unary_from_softmax(feat_first) 105 | unary = np.ascontiguousarray(unary) 106 | 107 | d = dcrf.DenseCRF2D(rgbim.shape[1], rgbim.shape[0], n_classes) 108 | 109 | d.setUnaryEnergy(unary) 110 | d.addPairwiseGaussian(sxy=1, compat=3, kernel=dcrf.DIAG_KERNEL, 111 | normalization=dcrf.NO_NORMALIZATION) 112 | 113 | d.addPairwiseBilateral(sxy=23, srgb=7, rgbim=rgbim, 114 | compat=20, 115 | kernel=dcrf.DIAG_KERNEL, 116 | normalization=dcrf.NO_NORMALIZATION) 117 | Q = d.inference(5) 118 | res = np.argmax(Q, axis=0).reshape((rgbim.shape[0], rgbim.shape[1])) 119 | crf_mask = np.array(res * 255, dtype=np.uint8) 120 | return crf_mask 121 | 122 | def complete_mask_fill(img_np: np.ndarray, ccs: List[np.ndarray], text_lines: List[Tuple[int, int, int, int]], cc2textline_assignment) : 123 | if len(ccs) == 0 : 124 | return 125 | for (x, y, w, h) in text_lines : 126 | final_mask = cv2.rectangle(final_mask, (x, y), (x + w, y + h), (255), -1) 127 | return final_mask 128 | 129 | def complete_mask(img_np: np.ndarray, ccs: List[np.ndarray], text_lines: List[Tuple[int, int, int, int]], cc2textline_assignment) : 130 | if len(ccs) == 0 : 131 | return 132 | textline_ccs = [np.zeros_like(ccs[0]) for _ in range(len(text_lines))] 133 | for i, cc in enumerate(ccs) : 134 | txtline = cc2textline_assignment[i] 135 | textline_ccs[txtline] = cv2.bitwise_or(textline_ccs[txtline], cc) 136 | final_mask = np.zeros_like(ccs[0]) 137 | img_np = cv2.bilateralFilter(img_np, 17, 80, 80) 138 | for i, cc in enumerate(tqdm(textline_ccs)) : 139 | x1, y1, w1, h1 = cv2.boundingRect(cc) 140 | text_size = min(w1, h1) 141 | extend_size = int(text_size * 0.1) 142 | x1 = max(x1 - extend_size, 0) 143 | y1 = max(y1 - extend_size, 0) 144 | w1 += extend_size * 2 145 | h1 += extend_size * 2 146 | w1 = min(w1, img_np.shape[1] - x1 - 1) 147 | h1 = min(h1, img_np.shape[0] - y1 - 1) 148 | dilate_size = max((int(text_size * 0.3) // 2) * 2 + 1, 3) 149 | kern = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (dilate_size, dilate_size)) 150 | cc_region = np.ascontiguousarray(cc[y1: y1 + h1, x1: x1 + w1]) 151 | if cc_region.size == 0 : 152 | continue 153 | #cv2.imshow('cc before', image_resize(cc_region, width = 256)) 154 | img_region = np.ascontiguousarray(img_np[y1: y1 + h1, x1: x1 + w1]) 155 | #cv2.imshow('img', image_resize(img_region, width = 256)) 156 | cc_region = refine_mask(img_region, cc_region) 157 | #cv2.imshow('cc after', image_resize(cc_region, width = 256)) 158 | #cv2.waitKey(0) 159 | cc[y1: y1 + h1, x1: x1 + w1] = cc_region 160 | cc = cv2.dilate(cc, kern) 161 | final_mask = cv2.bitwise_or(final_mask, cc) 162 | kern = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3)) 163 | # for (x, y, w, h) in text_lines : 164 | # final_mask = cv2.rectangle(final_mask, (x, y), (x + w, y + h), (255), -1) 165 | return cv2.dilate(final_mask, kern) 166 | 167 | def unsharp(image) : 168 | gaussian_3 = cv2.GaussianBlur(image, (3, 3), 2.0) 169 | return cv2.addWeighted(image, 1.5, gaussian_3, -0.5, 0, image) 170 | 171 | from utils import Quadrilateral 172 | 173 | def run_refine_mask(raw_image: np.ndarray, raw_mask: np.ndarray, textlines: List[Quadrilateral], method: str = 'fit_text') -> Tuple[np.ndarray, str] : 174 | mask_resized = cv2.resize(raw_mask, (raw_image.shape[1] // 2, raw_image.shape[0] // 2), interpolation = cv2.INTER_LINEAR) 175 | img_resized_2 = cv2.resize(raw_image, (raw_image.shape[1] // 2, raw_image.shape[0] // 2), interpolation = cv2.INTER_LINEAR) 176 | mask_resized[mask_resized > 0] = 255 177 | text_lines = [(int(a.aabb.x // 2), int(a.aabb.y // 2), int(a.aabb.w // 2), int(a.aabb.h // 2)) for a in textlines] 178 | mask_ccs, cc2textline_assignment = filter_masks(mask_resized, text_lines) 179 | if mask_ccs : 180 | if method == 'fit_text' : 181 | final_mask = complete_mask(img_resized_2, mask_ccs, text_lines, cc2textline_assignment) 182 | else : 183 | final_mask = complete_mask_fill(img_resized_2, mask_ccs, text_lines, cc2textline_assignment) 184 | final_mask = cv2.resize(final_mask, (raw_image.shape[1], raw_image.shape[0]), interpolation = cv2.INTER_LINEAR) 185 | final_mask[final_mask > 0] = 255 186 | else : 187 | final_mask = np.zeros((raw_image.shape[0], raw_image.shape[1]), dtype = np.uint8) 188 | return final_mask, "mask-refine-20220423" 189 | -------------------------------------------------------------------------------- /services/models/Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 3 4 | 5 | [[package]] 6 | name = "ahash" 7 | version = "0.7.6" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "fcb51a0695d8f838b1ee009b3fbf66bda078cd64590202a864a8f3e8c4315c47" 10 | dependencies = [ 11 | "getrandom", 12 | "once_cell", 13 | "version_check", 14 | ] 15 | 16 | [[package]] 17 | name = "autocfg" 18 | version = "1.1.0" 19 | source = "registry+https://github.com/rust-lang/crates.io-index" 20 | checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" 21 | 22 | [[package]] 23 | name = "base64" 24 | version = "0.13.0" 25 | source = "registry+https://github.com/rust-lang/crates.io-index" 26 | checksum = "904dfeac50f3cdaba28fc6f57fdcddb75f49ed61346676a78c4ffe55877802fd" 27 | 28 | [[package]] 29 | name = "bson" 30 | version = "2.1.0" 31 | source = "registry+https://github.com/rust-lang/crates.io-index" 32 | checksum = "41539b5c502b7c4e7b8af8ef07e5c442fe79ceba62a2aad8e62bd589b9454745" 33 | dependencies = [ 34 | "ahash", 35 | "base64", 36 | "chrono", 37 | "hex", 38 | "indexmap", 39 | "lazy_static", 40 | "rand", 41 | "serde", 42 | "serde_bytes", 43 | "serde_json", 44 | "uuid", 45 | ] 46 | 47 | [[package]] 48 | name = "cfg-if" 49 | version = "1.0.0" 50 | source = "registry+https://github.com/rust-lang/crates.io-index" 51 | checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" 52 | 53 | [[package]] 54 | name = "chrono" 55 | version = "0.4.19" 56 | source = "registry+https://github.com/rust-lang/crates.io-index" 57 | checksum = "670ad68c9088c2a963aaa298cb369688cf3f9465ce5e2d4ca10e6e0098a1ce73" 58 | dependencies = [ 59 | "num-integer", 60 | "num-traits", 61 | ] 62 | 63 | [[package]] 64 | name = "getrandom" 65 | version = "0.2.4" 66 | source = "registry+https://github.com/rust-lang/crates.io-index" 67 | checksum = "418d37c8b1d42553c93648be529cb70f920d3baf8ef469b74b9638df426e0b4c" 68 | dependencies = [ 69 | "cfg-if", 70 | "libc", 71 | "wasi", 72 | ] 73 | 74 | [[package]] 75 | name = "hashbrown" 76 | version = "0.11.2" 77 | source = "registry+https://github.com/rust-lang/crates.io-index" 78 | checksum = "ab5ef0d4909ef3724cc8cce6ccc8572c5c817592e9285f5464f8e86f8bd3726e" 79 | 80 | [[package]] 81 | name = "hex" 82 | version = "0.4.3" 83 | source = "registry+https://github.com/rust-lang/crates.io-index" 84 | checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" 85 | 86 | [[package]] 87 | name = "indexmap" 88 | version = "1.8.0" 89 | source = "registry+https://github.com/rust-lang/crates.io-index" 90 | checksum = "282a6247722caba404c065016bbfa522806e51714c34f5dfc3e4a3a46fcb4223" 91 | dependencies = [ 92 | "autocfg", 93 | "hashbrown", 94 | ] 95 | 96 | [[package]] 97 | name = "itoa" 98 | version = "1.0.1" 99 | source = "registry+https://github.com/rust-lang/crates.io-index" 100 | checksum = "1aab8fc367588b89dcee83ab0fd66b72b50b72fa1904d7095045ace2b0c81c35" 101 | 102 | [[package]] 103 | name = "lazy_static" 104 | version = "1.4.0" 105 | source = "registry+https://github.com/rust-lang/crates.io-index" 106 | checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" 107 | 108 | [[package]] 109 | name = "libc" 110 | version = "0.2.119" 111 | source = "registry+https://github.com/rust-lang/crates.io-index" 112 | checksum = "1bf2e165bb3457c8e098ea76f3e3bc9db55f87aa90d52d0e6be741470916aaa4" 113 | 114 | [[package]] 115 | name = "models" 116 | version = "0.1.0" 117 | dependencies = [ 118 | "bson", 119 | ] 120 | 121 | [[package]] 122 | name = "num-integer" 123 | version = "0.1.44" 124 | source = "registry+https://github.com/rust-lang/crates.io-index" 125 | checksum = "d2cc698a63b549a70bc047073d2949cce27cd1c7b0a4a862d08a8031bc2801db" 126 | dependencies = [ 127 | "autocfg", 128 | "num-traits", 129 | ] 130 | 131 | [[package]] 132 | name = "num-traits" 133 | version = "0.2.14" 134 | source = "registry+https://github.com/rust-lang/crates.io-index" 135 | checksum = "9a64b1ec5cda2586e284722486d802acf1f7dbdc623e2bfc57e65ca1cd099290" 136 | dependencies = [ 137 | "autocfg", 138 | ] 139 | 140 | [[package]] 141 | name = "once_cell" 142 | version = "1.9.0" 143 | source = "registry+https://github.com/rust-lang/crates.io-index" 144 | checksum = "da32515d9f6e6e489d7bc9d84c71b060db7247dc035bbe44eac88cf87486d8d5" 145 | 146 | [[package]] 147 | name = "ppv-lite86" 148 | version = "0.2.16" 149 | source = "registry+https://github.com/rust-lang/crates.io-index" 150 | checksum = "eb9f9e6e233e5c4a35559a617bf40a4ec447db2e84c20b55a6f83167b7e57872" 151 | 152 | [[package]] 153 | name = "proc-macro2" 154 | version = "1.0.36" 155 | source = "registry+https://github.com/rust-lang/crates.io-index" 156 | checksum = "c7342d5883fbccae1cc37a2353b09c87c9b0f3afd73f5fb9bba687a1f733b029" 157 | dependencies = [ 158 | "unicode-xid", 159 | ] 160 | 161 | [[package]] 162 | name = "quote" 163 | version = "1.0.15" 164 | source = "registry+https://github.com/rust-lang/crates.io-index" 165 | checksum = "864d3e96a899863136fc6e99f3d7cae289dafe43bf2c5ac19b70df7210c0a145" 166 | dependencies = [ 167 | "proc-macro2", 168 | ] 169 | 170 | [[package]] 171 | name = "rand" 172 | version = "0.8.5" 173 | source = "registry+https://github.com/rust-lang/crates.io-index" 174 | checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" 175 | dependencies = [ 176 | "libc", 177 | "rand_chacha", 178 | "rand_core", 179 | ] 180 | 181 | [[package]] 182 | name = "rand_chacha" 183 | version = "0.3.1" 184 | source = "registry+https://github.com/rust-lang/crates.io-index" 185 | checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" 186 | dependencies = [ 187 | "ppv-lite86", 188 | "rand_core", 189 | ] 190 | 191 | [[package]] 192 | name = "rand_core" 193 | version = "0.6.3" 194 | source = "registry+https://github.com/rust-lang/crates.io-index" 195 | checksum = "d34f1408f55294453790c48b2f1ebbb1c5b4b7563eb1f418bcfcfdbb06ebb4e7" 196 | dependencies = [ 197 | "getrandom", 198 | ] 199 | 200 | [[package]] 201 | name = "ryu" 202 | version = "1.0.9" 203 | source = "registry+https://github.com/rust-lang/crates.io-index" 204 | checksum = "73b4b750c782965c211b42f022f59af1fbceabdd026623714f104152f1ec149f" 205 | 206 | [[package]] 207 | name = "serde" 208 | version = "1.0.136" 209 | source = "registry+https://github.com/rust-lang/crates.io-index" 210 | checksum = "ce31e24b01e1e524df96f1c2fdd054405f8d7376249a5110886fb4b658484789" 211 | dependencies = [ 212 | "serde_derive", 213 | ] 214 | 215 | [[package]] 216 | name = "serde_bytes" 217 | version = "0.11.5" 218 | source = "registry+https://github.com/rust-lang/crates.io-index" 219 | checksum = "16ae07dd2f88a366f15bd0632ba725227018c69a1c8550a927324f8eb8368bb9" 220 | dependencies = [ 221 | "serde", 222 | ] 223 | 224 | [[package]] 225 | name = "serde_derive" 226 | version = "1.0.136" 227 | source = "registry+https://github.com/rust-lang/crates.io-index" 228 | checksum = "08597e7152fcd306f41838ed3e37be9eaeed2b61c42e2117266a554fab4662f9" 229 | dependencies = [ 230 | "proc-macro2", 231 | "quote", 232 | "syn", 233 | ] 234 | 235 | [[package]] 236 | name = "serde_json" 237 | version = "1.0.79" 238 | source = "registry+https://github.com/rust-lang/crates.io-index" 239 | checksum = "8e8d9fa5c3b304765ce1fd9c4c8a3de2c8db365a5b91be52f186efc675681d95" 240 | dependencies = [ 241 | "indexmap", 242 | "itoa", 243 | "ryu", 244 | "serde", 245 | ] 246 | 247 | [[package]] 248 | name = "syn" 249 | version = "1.0.86" 250 | source = "registry+https://github.com/rust-lang/crates.io-index" 251 | checksum = "8a65b3f4ffa0092e9887669db0eae07941f023991ab58ea44da8fe8e2d511c6b" 252 | dependencies = [ 253 | "proc-macro2", 254 | "quote", 255 | "unicode-xid", 256 | ] 257 | 258 | [[package]] 259 | name = "unicode-xid" 260 | version = "0.2.2" 261 | source = "registry+https://github.com/rust-lang/crates.io-index" 262 | checksum = "8ccb82d61f80a663efe1f787a51b16b5a51e3314d6ac365b08639f52387b33f3" 263 | 264 | [[package]] 265 | name = "uuid" 266 | version = "0.8.2" 267 | source = "registry+https://github.com/rust-lang/crates.io-index" 268 | checksum = "bc5cf98d8186244414c848017f0e2676b3fcb46807f6668a97dfe67359a3c4b7" 269 | dependencies = [ 270 | "getrandom", 271 | "serde", 272 | ] 273 | 274 | [[package]] 275 | name = "version_check" 276 | version = "0.9.4" 277 | source = "registry+https://github.com/rust-lang/crates.io-index" 278 | checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" 279 | 280 | [[package]] 281 | name = "wasi" 282 | version = "0.10.2+wasi-snapshot-preview1" 283 | source = "registry+https://github.com/rust-lang/crates.io-index" 284 | checksum = "fd6fbd9a79829dd1ad0cc20627bf1ed606756a7f77edff7b66b7064f9cb327c6" 285 | -------------------------------------------------------------------------------- /services/models/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "models" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 7 | 8 | [dependencies] 9 | bson = "2.0.1" 10 | -------------------------------------------------------------------------------- /services/models/src/lib.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashSet; 2 | 3 | use bson::oid::ObjectId; 4 | 5 | 6 | pub struct ImageSource { 7 | /// Canonicalized raw URL of source image (e.g. twitter CDN url) 8 | pub source_url: Option, 9 | /// Canonicalized URL of web page for this image (e.g. twitter url) 10 | pub source_page: Option, 11 | /// When is the image created, this is provided by its source website, fill this field whenever possible 12 | pub created_at: Option, 13 | /// When is the image uploaded to cotrans 14 | pub uploaded_at: bson::DateTime, 15 | /// Who uploaded this image to cotrans 16 | pub uploaded_by: String, 17 | } 18 | 19 | pub struct OriginalImage { 20 | /// Sources of this image 21 | /// Use vec in case of same image being reposted at multiple websites 22 | pub sources: Vec, 23 | /// Highest width of this image, there maybe multiple version of the same image exist 24 | /// we only keep the one with highest resolution 25 | pub width: u32, 26 | /// Highest height of this image, there maybe multiple version of the same image exist 27 | /// we only keep the one with highest resolution 28 | pub height: u32, 29 | /// File size in octets of the best version of this image 30 | pub file_size: u64, 31 | /// Format of this image (e.g. JPEG, PNG, WEBM) 32 | pub image_format: Option, 33 | /// Number of channels in this image (e.g. 1 for grayscale, 3 for RGB, 4 for RGBA) 34 | pub channels: u32, 35 | /// When is image with this phash value first uploaded to cotrans 36 | pub created_at: bson::DateTime, 37 | /// Who is the first one uploaded image with this phash value to cotrans 38 | pub created_by: String, 39 | 40 | /// Perceptual hash value of this image 41 | pub phash_value: String, 42 | /// Blockhash value of this image (optional) 43 | pub blockhash_value: Option, 44 | /// Wavelet hash value of this image (optional) 45 | pub whash_value: Option, 46 | 47 | /// Link to cotrans' store of this image, likely a Cloudflare image link 48 | pub url: String, 49 | /// Optional backup links to this image 50 | pub backup_urls: Option>, 51 | /// SHA256 hash of this image 52 | pub sha256_value: String 53 | } 54 | 55 | /// For flexibility, result does not have a fixed form 56 | pub enum FreeFormResult { 57 | JSON(String), 58 | XML(String) 59 | } 60 | 61 | pub struct TextExtractionResult { 62 | /// Link to mask generated 63 | pub mask_url: String, 64 | 65 | /// Format of result 66 | pub format: String, 67 | /// Extracted text 68 | pub result: FreeFormResult 69 | } 70 | 71 | /// Record of a automatic text extraction operation, incldues both metadata and result 72 | pub struct TextExtraction { 73 | /// Which image is this record for 74 | pub phash_value: String, 75 | /// When is this text extraction request made 76 | pub created_at: bson::DateTime, 77 | /// Who made this text extraction request 78 | pub created_by: String, 79 | /// How long did this request take to finish 80 | pub time_used_ms: u32, 81 | /// Log 82 | pub log: Option, 83 | 84 | /// Name and version of detector (e.g. `DBNet-RN101-v20210711`) 85 | pub detector: String, 86 | /// Name and version of OCR (e.g. `OCR-AR-48px-v20210921`) 87 | pub ocr: String, 88 | 89 | /// Height of image sent to detector 90 | pub detection_height: u32, 91 | /// Width of image sent to detector 92 | pub detection_width: u32, 93 | 94 | /// Extraction result 95 | pub result: TextExtractionResult 96 | } 97 | 98 | pub struct Inpainting { 99 | /// Inpainting model name and version (e.g. `LaMa-v20220220`) 100 | pub model: String, 101 | /// Link to inpainted image 102 | pub inpainted_url: String, 103 | /// Link to mask used during inpainting 104 | pub mask: String, 105 | /// Width of image used for inpainting 106 | pub width: u32, 107 | /// Height of image used for inpainting 108 | pub height: u32, 109 | /// Blending method used, one of `replace`, `poisson`, `cutout` 110 | pub blending: String 111 | } 112 | 113 | pub struct Typesetting { 114 | 115 | } 116 | 117 | pub struct TextRendering { 118 | 119 | } 120 | 121 | 122 | pub struct Translation { 123 | /// Which image is this record for 124 | pub phash_value: String, 125 | /// Which extraction result is this record based on 126 | pub extraction_id: Option, 127 | /// Which translation result is this record based on 128 | pub translation_id: Option, 129 | /// When is this translation created 130 | pub created_at: bson::DateTime, 131 | /// Who made this translation 132 | pub created_by: CreatedBy, 133 | 134 | /// Info related to inpaitning, empty if user did his own typesetting 135 | pub inpainting: Option, 136 | /// Info related typesetting 137 | pub typesetting: Option, 138 | /// Info related text rendering 139 | pub text_rendering: Option, 140 | 141 | /// Format of result 142 | pub format: String, 143 | /// Result 144 | pub result: FreeFormResult, 145 | /// Final image 146 | pub final_url: String, 147 | pub width: u32, 148 | pub height: u32, 149 | pub file_size: u64, 150 | /// Backup links to this image 151 | pub backup_urls: Option>, 152 | pub sha256_value: String, 153 | 154 | /// Upvotes 155 | pub upvotes: u32, 156 | /// Downvotes 157 | pub downvotes: u32, 158 | /// Score 159 | pub score: u32 160 | } 161 | 162 | pub struct User { 163 | pub created_at: bson::DateTime, 164 | pub password_hashed: Option 165 | } 166 | 167 | pub struct MachineTranslator { 168 | pub name: String, 169 | pub version: String 170 | } 171 | 172 | pub enum CreatedBy { 173 | User(ObjectId), 174 | Machine(MachineTranslator) 175 | } 176 | 177 | #[cfg(test)] 178 | mod tests { 179 | #[test] 180 | fn it_works() { 181 | let result = 2 + 2; 182 | assert_eq!(result, 4); 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /services/ocr_ctc_v1/main.py: -------------------------------------------------------------------------------- 1 | 2 | from typing import Any 3 | from models import * 4 | from ocr import load_model, run_ocr 5 | 6 | import numpy as np 7 | from PIL import Image 8 | import io 9 | import cv2 10 | import secrets 11 | 12 | from fastapi import FastAPI, Form, File, UploadFile, Response 13 | from fastapi.responses import JSONResponse 14 | 15 | from utils import Quadrilateral 16 | 17 | app = FastAPI() 18 | 19 | class JsonAndImageResponse(Response) : 20 | def render(self, content: Any) -> bytes: 21 | boundary = secrets.token_bytes(16).hex() 22 | json_data: dict = content["json"] 23 | img_data: Image.Image = content["img"] 24 | img_type: str = content["img-type"] 25 | self.media_type = f"multipart/related; boundary={boundary}; start=\"jsonData\";" 26 | json_content = json.dumps(json_data) 27 | ans = io.BytesIO() 28 | ans.write(f"--{boundary}\r\n".encode('utf-8')) 29 | ans.write(b"Content-Type: application/json\r\nContent-ID: jsonData\r\n\r\n") 30 | ans.write(json_content.encode('utf-8')) 31 | ans.write(f"\r\n--{boundary}\r\n".encode('utf-8')) 32 | ans.write(f"Content-Type: image/{img_type}\r\nContent-ID: imageData\r\n\r\n".encode('utf-8')) 33 | img_data.save(ans, format = 'PNG') 34 | ans.write(f"\r\n--{boundary}--".encode('utf-8')) 35 | return ans.getvalue() 36 | 37 | def get_bbox(tref: TextRegionExchangeFormat, w: int, h: int) : 38 | if tref.fmt == 'quad' : 39 | return Quadrilateral.from_tref(tref, w, h) 40 | elif tref.fmt == 'textbox' : 41 | raise NotImplemented 42 | 43 | @app.post("/v1/ocr") 44 | async def ocr(config: V1OCRCTCRequest = Form(), image: UploadFile = File()) : 45 | img = Image.open(image.file) 46 | w, h = img.width, img.height 47 | img_np = np.asarray(img) 48 | regions = [get_bbox(t, w, h) for t in config.regions] 49 | quads, version = run_ocr(app, config, img_np, regions) 50 | resp = V1OCRCTCResponse(texts = [t.to_tref_and_normalize(w, h) for t in quads], version = version) 51 | return JSONResponse(resp.dict(exclude_none = True)) 52 | 53 | @app.on_event("startup") 54 | async def startup_event() : 55 | load_model(app) 56 | -------------------------------------------------------------------------------- /services/ocr_ctc_v1/models.py: -------------------------------------------------------------------------------- 1 | 2 | from __future__ import annotations 3 | from typing import List, Optional, Tuple 4 | from pydantic import BaseModel, ValidationError, validator 5 | 6 | import json 7 | 8 | class TextRegionExchangeFormat(BaseModel) : 9 | fmt: str 10 | coords: str 11 | fg: Optional[Tuple[int, int, int]] 12 | bg: Optional[Tuple[int, int, int]] 13 | text: Optional[str] 14 | prob: Optional[float] = 0 15 | direction: Optional[str] 16 | lines: List[TextRegionExchangeFormat] = [] 17 | 18 | TextRegionExchangeFormat.update_forward_refs() 19 | 20 | class V1OCRCTCRequest(BaseModel) : 21 | regions: List[TextRegionExchangeFormat] 22 | max_chunk_size: int = 16 23 | cuda: bool = False 24 | text_prob_threshold: float = 0.3 25 | 26 | @classmethod 27 | def __get_validators__(cls): 28 | yield cls.validate_to_json 29 | 30 | @classmethod 31 | def validate_to_json(cls, value): 32 | if isinstance(value, str): 33 | return cls(**json.loads(value)) 34 | return value 35 | 36 | class V1OCRCTCResponse(BaseModel) : 37 | texts: List[TextRegionExchangeFormat] 38 | version: str 39 | 40 | @classmethod 41 | def __get_validators__(cls): 42 | yield cls.validate_to_json 43 | 44 | @classmethod 45 | def validate_to_json(cls, value): 46 | if isinstance(value, str): 47 | return cls(**json.loads(value)) 48 | return value 49 | -------------------------------------------------------------------------------- /services/phash/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "phash" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 7 | 8 | [dependencies] 9 | image = "0.23.14" 10 | img_hash = "3.2.0" 11 | hex = "0.4.3" 12 | -------------------------------------------------------------------------------- /services/phash/src/main.rs: -------------------------------------------------------------------------------- 1 | fn main() { 2 | println!("Hello, world!"); 3 | } 4 | -------------------------------------------------------------------------------- /services/text_detection_v1/dbnet_utils.py: -------------------------------------------------------------------------------- 1 | 2 | import pyclipper 3 | import cv2 4 | import numpy as np 5 | from shapely.geometry import Polygon 6 | 7 | class SegDetectorRepresenter(): 8 | def __init__(self, thresh=0.6, box_thresh=0.8, max_candidates=1000, unclip_ratio=2.2): 9 | self.min_size = 3 10 | self.thresh = thresh 11 | self.box_thresh = box_thresh 12 | self.max_candidates = max_candidates 13 | self.unclip_ratio = unclip_ratio 14 | 15 | def __call__(self, batch, pred, is_output_polygon=False): 16 | ''' 17 | batch: (image, polygons, ignore_tags 18 | batch: a dict produced by dataloaders. 19 | image: tensor of shape (N, C, H, W). 20 | polygons: tensor of shape (N, K, 4, 2), the polygons of objective regions. 21 | ignore_tags: tensor of shape (N, K), indicates whether a region is ignorable or not. 22 | shape: the original shape of images. 23 | filename: the original filenames of images. 24 | pred: 25 | binary: text region segmentation map, with shape (N, H, W) 26 | thresh: [if exists] thresh hold prediction with shape (N, H, W) 27 | thresh_binary: [if exists] binarized with threshhold, (N, H, W) 28 | ''' 29 | pred = pred[:, 0, :, :] 30 | segmentation = self.binarize(pred) 31 | boxes_batch = [] 32 | scores_batch = [] 33 | for batch_index in range(pred.size(0)): 34 | height, width = batch['shape'][batch_index] 35 | if is_output_polygon: 36 | boxes, scores = self.polygons_from_bitmap(pred[batch_index], segmentation[batch_index], width, height) 37 | else: 38 | boxes, scores = self.boxes_from_bitmap(pred[batch_index], segmentation[batch_index], width, height) 39 | boxes_batch.append(boxes) 40 | scores_batch.append(scores) 41 | return boxes_batch, scores_batch 42 | 43 | def binarize(self, pred): 44 | return pred > self.thresh 45 | 46 | def polygons_from_bitmap(self, pred, _bitmap, dest_width, dest_height): 47 | ''' 48 | _bitmap: single map with shape (H, W), 49 | whose values are binarized as {0, 1} 50 | ''' 51 | 52 | assert len(_bitmap.shape) == 2 53 | bitmap = _bitmap.cpu().numpy() # The first channel 54 | pred = pred.cpu().detach().numpy() 55 | height, width = bitmap.shape 56 | boxes = [] 57 | scores = [] 58 | 59 | contours, _ = cv2.findContours((bitmap * 255).astype(np.uint8), cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE) 60 | 61 | for contour in contours[:self.max_candidates]: 62 | epsilon = 0.005 * cv2.arcLength(contour, True) 63 | approx = cv2.approxPolyDP(contour, epsilon, True) 64 | points = approx.reshape((-1, 2)) 65 | if points.shape[0] < 4: 66 | continue 67 | # _, sside = self.get_mini_boxes(contour) 68 | # if sside < self.min_size: 69 | # continue 70 | score = self.box_score_fast(pred, contour.squeeze(1)) 71 | if self.box_thresh > score: 72 | continue 73 | 74 | if points.shape[0] > 2: 75 | box = self.unclip(points, unclip_ratio=self.unclip_ratio) 76 | if len(box) > 1: 77 | continue 78 | else: 79 | continue 80 | box = box.reshape(-1, 2) 81 | _, sside = self.get_mini_boxes(box.reshape((-1, 1, 2))) 82 | if sside < self.min_size + 2: 83 | continue 84 | 85 | if not isinstance(dest_width, int): 86 | dest_width = dest_width.item() 87 | dest_height = dest_height.item() 88 | 89 | box[:, 0] = np.clip(np.round(box[:, 0] / width * dest_width), 0, dest_width) 90 | box[:, 1] = np.clip(np.round(box[:, 1] / height * dest_height), 0, dest_height) 91 | boxes.append(box) 92 | scores.append(score) 93 | return boxes, scores 94 | 95 | def boxes_from_bitmap(self, pred, _bitmap, dest_width, dest_height): 96 | ''' 97 | _bitmap: single map with shape (H, W), 98 | whose values are binarized as {0, 1} 99 | ''' 100 | 101 | assert len(_bitmap.shape) == 2 102 | bitmap = _bitmap.cpu().numpy() # The first channel 103 | pred = pred.cpu().detach().numpy() 104 | height, width = bitmap.shape 105 | try : 106 | contours, _ = cv2.findContours((bitmap * 255).astype(np.uint8), cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE) 107 | except ValueError : 108 | return [], [] 109 | num_contours = min(len(contours), self.max_candidates) 110 | boxes = np.zeros((num_contours, 4, 2), dtype=np.int16) 111 | scores = np.zeros((num_contours,), dtype=np.float32) 112 | 113 | for index in range(num_contours): 114 | contour = contours[index].squeeze(1) 115 | points, sside = self.get_mini_boxes(contour) 116 | if sside < self.min_size: 117 | continue 118 | points = np.array(points) 119 | score = self.box_score_fast(pred, contour) 120 | if self.box_thresh > score: 121 | continue 122 | 123 | box = self.unclip(points, unclip_ratio=self.unclip_ratio).reshape(-1, 1, 2) 124 | box, sside = self.get_mini_boxes(box) 125 | if sside < self.min_size + 2: 126 | continue 127 | box = np.array(box) 128 | if not isinstance(dest_width, int): 129 | dest_width = dest_width.item() 130 | dest_height = dest_height.item() 131 | 132 | box[:, 0] = np.clip(np.round(box[:, 0] / width * dest_width), 0, dest_width) 133 | box[:, 1] = np.clip(np.round(box[:, 1] / height * dest_height), 0, dest_height) 134 | startidx = box.sum(axis=1).argmin() 135 | box = np.roll(box, 4-startidx, 0) 136 | box = np.array(box) 137 | boxes[index, :, :] = box.astype(np.int16) 138 | scores[index] = score 139 | return boxes, scores 140 | 141 | def unclip(self, box, unclip_ratio=1.8): 142 | poly = Polygon(box) 143 | distance = poly.area * unclip_ratio / poly.length 144 | offset = pyclipper.PyclipperOffset() 145 | offset.AddPath(box, pyclipper.JT_ROUND, pyclipper.ET_CLOSEDPOLYGON) 146 | expanded = np.array(offset.Execute(distance)) 147 | return expanded 148 | 149 | def get_mini_boxes(self, contour): 150 | bounding_box = cv2.minAreaRect(contour) 151 | points = sorted(list(cv2.boxPoints(bounding_box)), key=lambda x: x[0]) 152 | 153 | index_1, index_2, index_3, index_4 = 0, 1, 2, 3 154 | if points[1][1] > points[0][1]: 155 | index_1 = 0 156 | index_4 = 1 157 | else: 158 | index_1 = 1 159 | index_4 = 0 160 | if points[3][1] > points[2][1]: 161 | index_2 = 2 162 | index_3 = 3 163 | else: 164 | index_2 = 3 165 | index_3 = 2 166 | 167 | box = [points[index_1], points[index_2], points[index_3], points[index_4]] 168 | return box, min(bounding_box[1]) 169 | 170 | def box_score_fast(self, bitmap, _box): 171 | h, w = bitmap.shape[:2] 172 | box = _box.copy() 173 | xmin = np.clip(np.floor(box[:, 0].min()).astype(np.int32), 0, w - 1) 174 | xmax = np.clip(np.ceil(box[:, 0].max()).astype(np.int32), 0, w - 1) 175 | ymin = np.clip(np.floor(box[:, 1].min()).astype(np.int32), 0, h - 1) 176 | ymax = np.clip(np.ceil(box[:, 1].max()).astype(np.int32), 0, h - 1) 177 | 178 | mask = np.zeros((ymax - ymin + 1, xmax - xmin + 1), dtype=np.uint8) 179 | box[:, 0] = box[:, 0] - xmin 180 | box[:, 1] = box[:, 1] - ymin 181 | cv2.fillPoly(mask, box.reshape(1, -1, 2).astype(np.int32), 1) 182 | return cv2.mean(bitmap[ymin:ymax + 1, xmin:xmax + 1], mask)[0] 183 | -------------------------------------------------------------------------------- /services/text_detection_v1/main.py: -------------------------------------------------------------------------------- 1 | 2 | from typing import Any 3 | from models import * 4 | from detector import load_model, run_detection 5 | 6 | import numpy as np 7 | from PIL import Image 8 | import io 9 | import cv2 10 | import secrets 11 | 12 | from fastapi import FastAPI, Form, File, UploadFile, Response 13 | app = FastAPI() 14 | 15 | class JsonAndImageResponse(Response) : 16 | def render(self, content: Any) -> bytes: 17 | boundary = secrets.token_bytes(16).hex() 18 | json_data: dict = content["json"] 19 | img_data: Image.Image = content["img"] 20 | img_type: str = content["img-type"] 21 | self.media_type = f"multipart/related; boundary={boundary}; start=\"jsonData\";" 22 | json_content = json.dumps(json_data) 23 | ans = io.BytesIO() 24 | ans.write(f"--{boundary}\r\n".encode('utf-8')) 25 | ans.write(b"Content-Type: application/json\r\nContent-ID: jsonData\r\n\r\n") 26 | ans.write(json_content.encode('utf-8')) 27 | ans.write(f"\r\n--{boundary}\r\n".encode('utf-8')) 28 | ans.write(f"Content-Type: image/{img_type}\r\nContent-ID: imageData\r\n\r\n".encode('utf-8')) 29 | img_data.save(ans, format = 'PNG') 30 | ans.write(f"\r\n--{boundary}--".encode('utf-8')) 31 | return ans.getvalue() 32 | 33 | class NparrayAndImageResponse(Response) : 34 | def render(self, content: Any) -> bytes: 35 | boundary = secrets.token_bytes(16).hex() 36 | np_data: np.ndarray = content["array"] 37 | img_data: Image.Image = content["img"] 38 | img_type: str = content["img-type"] 39 | self.media_type = f"multipart/related; boundary={boundary}; start=\"npData\";" 40 | ans = io.BytesIO() 41 | ans.write(f"--{boundary}\r\n".encode('utf-8')) 42 | ans.write(b"Content-Type: application/vnd.voilelabs.nparray\r\nContent-ID: npData\r\n\r\n") 43 | np.save(ans, np_data) 44 | ans.write(f"\r\n--{boundary}\r\n".encode('utf-8')) 45 | ans.write(f"Content-Type: image/{img_type}\r\nContent-ID: imageData\r\n\r\n".encode('utf-8')) 46 | img_data.save(ans, format = 'PNG') 47 | ans.write(f"\r\n--{boundary}--".encode('utf-8')) 48 | return ans.getvalue() 49 | 50 | class JsonAndNparrayAndImageResponse(Response) : 51 | def render(self, content: Any) -> bytes: 52 | boundary = secrets.token_bytes(16).hex() 53 | json_data: dict = content["json"] 54 | np_data: np.ndarray = content["array"] 55 | img_data: Image.Image = content["img"] 56 | img_type: str = content["img-type"] 57 | self.media_type = f"multipart/related; boundary={boundary}; start=\"jsonData\";" 58 | ans = io.BytesIO() 59 | json_content = json.dumps(json_data) 60 | ans.write(f"--{boundary}\r\n".encode('utf-8')) 61 | ans.write(b"Content-Type: application/json\r\nContent-ID: jsonData\r\n\r\n") 62 | ans.write(json_content.encode('utf-8')) 63 | ans.write(f"--{boundary}\r\n".encode('utf-8')) 64 | ans.write(b"Content-Type: application/vnd.voilelabs.nparray\r\nContent-ID: npData\r\n\r\n") 65 | np.save(ans, np_data) 66 | ans.write(f"\r\n--{boundary}\r\n".encode('utf-8')) 67 | ans.write(f"Content-Type: image/{img_type}\r\nContent-ID: imageData\r\n\r\n".encode('utf-8')) 68 | img_data.save(ans, format = 'PNG') 69 | ans.write(f"\r\n--{boundary}--".encode('utf-8')) 70 | return ans.getvalue() 71 | 72 | @app.post("/v1/detect") 73 | async def detect(config: V1TextDetectionRequest = Form(), image: UploadFile = File()) : 74 | img = Image.open(image.file) 75 | img_np = np.asarray(img) 76 | textlines, mask_np, version = run_detection(app, config, img_np) 77 | resp = V1TextDetectionResponse(regions = textlines, version = version) 78 | return JsonAndImageResponse({"json": resp.dict(exclude_none = True), "img": Image.fromarray(mask_np), "img-type": "png"}) 79 | 80 | @app.on_event("startup") 81 | async def startup_event() : 82 | load_model(app) 83 | -------------------------------------------------------------------------------- /services/text_detection_v1/models.py: -------------------------------------------------------------------------------- 1 | 2 | from __future__ import annotations 3 | from typing import List, Optional, Tuple 4 | from pydantic import BaseModel 5 | 6 | import json 7 | 8 | class TextRegionExchangeFormat(BaseModel) : 9 | fmt: str 10 | coords: str 11 | fg: Optional[Tuple[int, int, int]] 12 | bg: Optional[Tuple[int, int, int]] 13 | text: Optional[str] 14 | prob: Optional[float] = 0 15 | direction: Optional[str] 16 | lines: List[TextRegionExchangeFormat] = [] 17 | 18 | TextRegionExchangeFormat.update_forward_refs() 19 | 20 | class V1TextDetectionRequest(BaseModel): 21 | text_threshold: float = 0.5 22 | box_threshold: float = 0.7 23 | area_threshold: float = 16 24 | unclip_ratio: float = 2.3 25 | cuda: bool = False 26 | blur: bool = True 27 | blur_ks: int = 17 28 | blur_sigma_color: int = 80 29 | blur_sigma_space: int = 80 30 | target_ratio: float 31 | pad_width: int 32 | pad_height: int 33 | width: int # original image width 34 | height: int # original image height 35 | 36 | @classmethod 37 | def __get_validators__(cls): 38 | yield cls.validate_to_json 39 | 40 | @classmethod 41 | def validate_to_json(cls, value): 42 | if isinstance(value, str): 43 | return cls(**json.loads(value)) 44 | return value 45 | 46 | 47 | class V1TextDetectionResponse(BaseModel) : 48 | regions: List[TextRegionExchangeFormat] 49 | version: str 50 | 51 | @classmethod 52 | def __get_validators__(cls): 53 | yield cls.validate_to_json 54 | 55 | @classmethod 56 | def validate_to_json(cls, value): 57 | if isinstance(value, str): 58 | return cls(**json.loads(value)) 59 | return value 60 | 61 | -------------------------------------------------------------------------------- /services/textline_merge_v1/main.py: -------------------------------------------------------------------------------- 1 | 2 | from typing import Any 3 | from models import * 4 | from textline_merge import run_merge 5 | 6 | import numpy as np 7 | from PIL import Image 8 | import io 9 | import cv2 10 | import secrets 11 | 12 | from fastapi import FastAPI, Body 13 | from fastapi.responses import JSONResponse 14 | app = FastAPI() 15 | 16 | @app.post("/v1/merge") 17 | async def merge(config: V1TextlineMergeRequest) : 18 | if isinstance(config, dict) : 19 | config = V1TextlineMergeRequest.parse_obj(config) 20 | textlines = [Quadrilateral.from_tref(t, config.width, config.height) for t in config.textlines] 21 | regions, version = run_merge(config, textlines, config.width, config.height) 22 | resp = V1TextlineMergeResponse(regions = [r.to_tref_and_normalize(config.width, config.height) for r in regions], version = version) 23 | return JSONResponse(resp.dict(exclude_none = True)) 24 | -------------------------------------------------------------------------------- /services/textline_merge_v1/models.py: -------------------------------------------------------------------------------- 1 | 2 | from __future__ import annotations 3 | from typing import List, Optional, Tuple 4 | from pydantic import BaseModel 5 | 6 | import json 7 | 8 | class TextRegionExchangeFormat(BaseModel) : 9 | fmt: str 10 | coords: str 11 | fg: Optional[Tuple[int, int, int]] 12 | bg: Optional[Tuple[int, int, int]] 13 | text: Optional[str] 14 | prob: Optional[float] = 0 15 | direction: Optional[str] 16 | lines: List[TextRegionExchangeFormat] = [] 17 | 18 | from utils import Quadrilateral 19 | 20 | TextRegionExchangeFormat.update_forward_refs() 21 | 22 | class V1TextlineMergeRequest(BaseModel) : 23 | gamma: float = 0.5 24 | sigma: float = 2 25 | std_threshold: float = 6 26 | width: int 27 | height: int 28 | textlines: List[TextRegionExchangeFormat] 29 | 30 | @classmethod 31 | def __get_validators__(cls): 32 | yield cls.validate_to_json 33 | 34 | @classmethod 35 | def validate_to_json(cls, value): 36 | if isinstance(value, str): 37 | return cls(**json.loads(value)) 38 | return value 39 | 40 | class V1TextlineMergeResponse(BaseModel) : 41 | version: str 42 | regions: List[TextRegionExchangeFormat] 43 | 44 | @classmethod 45 | def __get_validators__(cls): 46 | yield cls.validate_to_json 47 | 48 | @classmethod 49 | def validate_to_json(cls, value): 50 | if isinstance(value, str): 51 | return cls(**json.loads(value)) 52 | return value 53 | -------------------------------------------------------------------------------- /services/textline_merge_v1/textline_merge.py: -------------------------------------------------------------------------------- 1 | 2 | from collections import Counter 3 | import itertools 4 | from typing import List, Set, Tuple 5 | import unicodedata 6 | 7 | import cv2 8 | from utils import Quadrilateral, quadrilateral_can_merge_region_coarse 9 | import numpy as np 10 | import networkx as nx 11 | 12 | def _is_whitespace(ch): 13 | """Checks whether `chars` is a whitespace character.""" 14 | # \t, \n, and \r are technically contorl characters but we treat them 15 | # as whitespace since they are generally considered as such. 16 | if ch == " " or ch == "\t" or ch == "\n" or ch == "\r" or ord(ch) == 0: 17 | return True 18 | cat = unicodedata.category(ch) 19 | if cat == "Zs": 20 | return True 21 | return False 22 | 23 | 24 | def _is_control(ch): 25 | """Checks whether `chars` is a control character.""" 26 | """Checks whether `chars` is a whitespace character.""" 27 | # These are technically control characters but we count them as whitespace 28 | # characters. 29 | if ch == "\t" or ch == "\n" or ch == "\r": 30 | return False 31 | cat = unicodedata.category(ch) 32 | if cat in ("Cc", "Cf"): 33 | return True 34 | return False 35 | 36 | 37 | def _is_punctuation(ch): 38 | """Checks whether `chars` is a punctuation character.""" 39 | """Checks whether `chars` is a whitespace character.""" 40 | cp = ord(ch) 41 | # We treat all non-letter/number ASCII as punctuation. 42 | # Characters such as "^", "$", and "`" are not in the Unicode 43 | # Punctuation class but we treat them as punctuation anyways, for 44 | # consistency. 45 | if ((cp >= 33 and cp <= 47) or (cp >= 58 and cp <= 64) or 46 | (cp >= 91 and cp <= 96) or (cp >= 123 and cp <= 126)): 47 | return True 48 | cat = unicodedata.category(ch) 49 | if cat.startswith("P"): 50 | return True 51 | return False 52 | 53 | def split_text_region(bboxes: List[Quadrilateral], region_indices: Set[int], gamma = 0.5, sigma = 2, std_threshold = 6.0) -> List[Set[int]] : 54 | region_indices = list(region_indices) 55 | if len(region_indices) == 1 : 56 | # case #1 57 | return [set(region_indices)] 58 | if len(region_indices) == 2 : 59 | # case #2 60 | fs1 = bboxes[region_indices[0]].font_size 61 | fs2 = bboxes[region_indices[1]].font_size 62 | fs = max(fs1, fs2) 63 | if bboxes[region_indices[0]].distance(bboxes[region_indices[1]]) < (1 + gamma) * fs \ 64 | and abs(bboxes[region_indices[0]].angle - bboxes[region_indices[1]].angle) < 4 * np.pi / 180 : 65 | return [set(region_indices)] 66 | else : 67 | return [set([region_indices[0]]), set([region_indices[1]])] 68 | # case 3 69 | G = nx.Graph() 70 | for idx in region_indices : 71 | G.add_node(idx) 72 | for (u, v) in itertools.combinations(region_indices, 2) : 73 | G.add_edge(u, v, weight = bboxes[u].distance(bboxes[v])) 74 | edges = nx.algorithms.tree.minimum_spanning_edges(G, algorithm = "kruskal", data = True) 75 | edges = sorted(edges, key = lambda a: a[2]['weight'], reverse = True) 76 | edge_weights = [a[2]['weight'] for a in edges] 77 | fontsize = np.mean([bboxes[idx].font_size for idx in region_indices]) 78 | std = np.std(edge_weights) 79 | mean = np.mean(edge_weights) 80 | if (edge_weights[0] <= mean + std * sigma or edge_weights[0] <= fontsize * (1 + gamma)) and std < std_threshold : 81 | return [set(region_indices)] 82 | else : 83 | if edge_weights[0] - edge_weights[1] < std * sigma and std < std_threshold : 84 | return [set(region_indices)] 85 | G = nx.Graph() 86 | for idx in region_indices : 87 | G.add_node(idx) 88 | for edge in edges[1:] : 89 | G.add_edge(edge[0], edge[1]) 90 | ans = [] 91 | for node_set in nx.algorithms.components.connected_components(G) : 92 | ans.extend(split_text_region(bboxes, node_set, gamma = gamma, sigma = sigma, std_threshold = std_threshold)) 93 | return ans 94 | pass 95 | 96 | def count_valuable_text(text) : 97 | return sum([1 for ch in text if not _is_punctuation(ch) and not _is_control(ch) and not _is_whitespace(ch)]) 98 | 99 | 100 | def get_mini_boxes(contour): 101 | bounding_box = cv2.minAreaRect(contour) 102 | points = sorted(list(cv2.boxPoints(bounding_box)), key=lambda x: x[0]) 103 | 104 | index_1, index_2, index_3, index_4 = 0, 1, 2, 3 105 | if points[1][1] > points[0][1]: 106 | index_1 = 0 107 | index_4 = 1 108 | else: 109 | index_1 = 1 110 | index_4 = 0 111 | if points[3][1] > points[2][1]: 112 | index_2 = 2 113 | index_3 = 3 114 | else: 115 | index_2 = 3 116 | index_3 = 2 117 | 118 | box = [points[index_1], points[index_2], points[index_3], points[index_4]] 119 | box = np.array(box) 120 | startidx = box.sum(axis=1).argmin() 121 | box = np.roll(box, 4-startidx, 0) 122 | box = np.array(box) 123 | return box 124 | 125 | def merge_bboxes_text_region(bboxes: List[Quadrilateral], width, height, gamma = 0.5, sigma = 2, std_threshold = 6.0) : 126 | G = nx.Graph() 127 | for i, box in enumerate(bboxes) : 128 | G.add_node(i, box = box) 129 | bboxes[i].assigned_index = i 130 | # step 1: roughly divide into multiple text region candidates 131 | for ((u, ubox), (v, vbox)) in itertools.combinations(enumerate(bboxes), 2) : 132 | if quadrilateral_can_merge_region_coarse(ubox, vbox) : 133 | G.add_edge(u, v) 134 | 135 | region_indices: List[Set[int]] = [] 136 | for node_set in nx.algorithms.components.connected_components(G) : 137 | # step 2: split each region 138 | region_indices.extend(split_text_region(bboxes, node_set, gamma, sigma, std_threshold)) 139 | 140 | for node_set in region_indices : 141 | nodes = list(node_set) 142 | # get overall bbox 143 | txtlns = np.array(bboxes)[nodes] 144 | kq = np.concatenate([x.pts for x in txtlns], axis = 0) 145 | if sum([int(a.is_approximate_axis_aligned) for a in txtlns]) > len(txtlns) // 2 : 146 | max_coord = np.max(kq, axis = 0) 147 | min_coord = np.min(kq, axis = 0) 148 | merged_box = np.maximum(np.array([ 149 | np.array([min_coord[0], min_coord[1]]), 150 | np.array([max_coord[0], min_coord[1]]), 151 | np.array([max_coord[0], max_coord[1]]), 152 | np.array([min_coord[0], max_coord[1]]) 153 | ]), 0) 154 | bbox = np.concatenate([a[None, :] for a in merged_box], axis = 0).astype(int) 155 | else : 156 | # TODO: use better method 157 | bbox = np.concatenate([a[None, :] for a in get_mini_boxes(kq)], axis = 0).astype(int) 158 | # calculate average fg and bg color 159 | fg_r = round(np.mean([box.fg_r for box in [bboxes[i] for i in nodes]])) 160 | fg_g = round(np.mean([box.fg_g for box in [bboxes[i] for i in nodes]])) 161 | fg_b = round(np.mean([box.fg_b for box in [bboxes[i] for i in nodes]])) 162 | bg_r = round(np.mean([box.bg_r for box in [bboxes[i] for i in nodes]])) 163 | bg_g = round(np.mean([box.bg_g for box in [bboxes[i] for i in nodes]])) 164 | bg_b = round(np.mean([box.bg_b for box in [bboxes[i] for i in nodes]])) 165 | # majority vote for direction 166 | dirs = [box.direction for box in [bboxes[i] for i in nodes]] 167 | majority_dir = Counter(dirs).most_common(1)[0][0] 168 | # sort 169 | if majority_dir == 'h' : 170 | nodes = sorted(nodes, key = lambda x: bboxes[x].aabb.y + bboxes[x].aabb.h // 2) 171 | elif majority_dir == 'v' : 172 | nodes = sorted(nodes, key = lambda x: -(bboxes[x].aabb.x + bboxes[x].aabb.w)) 173 | # yield overall bbox and sorted indices 174 | yield bbox, nodes, majority_dir, fg_r, fg_g, fg_b, bg_r, bg_g, bg_b 175 | 176 | from models import V1TextlineMergeRequest 177 | 178 | def run_merge(cfg: V1TextlineMergeRequest, textlines: List[Quadrilateral], width: int, height: int) -> Tuple[List[Quadrilateral], str] : 179 | text_regions: List[Quadrilateral] = [] 180 | new_textlines = [] 181 | for (poly_regions, textline_indices, majority_dir, fg_r, fg_g, fg_b, bg_r, bg_g, bg_b) in merge_bboxes_text_region(textlines, width, height, cfg.gamma, cfg.sigma, cfg.std_threshold) : 182 | text = '' 183 | logprob_lengths = [] 184 | for textline_idx in textline_indices : 185 | if not text : 186 | text = textlines[textline_idx].text 187 | else : 188 | last_ch = text[-1] 189 | cur_ch = textlines[textline_idx].text[0] 190 | if ord(last_ch) > 255 and ord(cur_ch) > 255 : 191 | text += textlines[textline_idx].text 192 | else : 193 | if last_ch == '-' and ord(cur_ch) < 255 : 194 | text = text[:-1] + textlines[textline_idx].text 195 | else : 196 | text += ' ' + textlines[textline_idx].text 197 | logprob_lengths.append((np.log(textlines[textline_idx].prob), len(textlines[textline_idx].text))) 198 | vc = count_valuable_text(text) 199 | total_logprobs = 0.0 200 | for (logprob, length) in logprob_lengths : 201 | total_logprobs += logprob * length 202 | total_logprobs /= sum([x[1] for x in logprob_lengths]) 203 | # filter text region without characters 204 | if vc > 1 : 205 | region = Quadrilateral(poly_regions, text, np.exp(total_logprobs), fg_r, fg_g, fg_b, bg_r, bg_g, bg_b) 206 | region.clip(width, height) 207 | region.assigned_direction = majority_dir 208 | text_regions.append(region) 209 | for textline_idx in textline_indices : 210 | region.lines.append(textlines[textline_idx]) 211 | return text_regions, "textline-merge-20220423" 212 | -------------------------------------------------------------------------------- /services/wasm/.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | **/*.rs.bk 3 | Cargo.lock 4 | bin/ 5 | pkg/ 6 | wasm-pack.log 7 | -------------------------------------------------------------------------------- /services/wasm/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "wasm" 3 | version = "0.1.0" 4 | authors = ["QiroNT"] 5 | edition = "2021" 6 | 7 | [lib] 8 | crate-type = ["cdylib", "rlib"] 9 | 10 | [features] 11 | default = ["wee_alloc"] 12 | 13 | [dependencies] 14 | wasm-bindgen = "0.2.63" 15 | 16 | # The `console_error_panic_hook` crate provides better debugging of panics by 17 | # logging them with `console.error`. This is great for development, but requires 18 | # all the `std::fmt` and `std::panicking` infrastructure, so isn't great for 19 | # code size when deploying. 20 | console_error_panic_hook = { version = "0.1.6", optional = true } 21 | 22 | # `wee_alloc` is a tiny allocator for wasm that is only ~1K in code size 23 | # compared to the default allocator's ~10K. It is slower than the default 24 | # allocator, however. 25 | # 26 | # Unfortunately, `wee_alloc` requires nightly Rust when targeting wasm for now. 27 | wee_alloc = { version = "0.4.5", optional = true } 28 | 29 | image = "0.23.14" 30 | img_hash = "3.2.0" 31 | hex = "0.4.3" 32 | 33 | [dev-dependencies] 34 | wasm-bindgen-test = "0.3.13" 35 | 36 | [profile.release] 37 | # Tell `rustc` to optimize for small code size. 38 | opt-level = "s" 39 | -------------------------------------------------------------------------------- /services/wasm/rust-toolchain: -------------------------------------------------------------------------------- 1 | nightly -------------------------------------------------------------------------------- /services/wasm/src/lib.rs: -------------------------------------------------------------------------------- 1 | mod utils; 2 | 3 | use image::{ImageBuffer, RgbaImage}; 4 | use img_hash::{FilterType, HasherConfig}; 5 | use wasm_bindgen::prelude::*; 6 | 7 | // When the `wee_alloc` feature is enabled, use `wee_alloc` as the global 8 | // allocator. 9 | #[cfg(feature = "wee_alloc")] 10 | #[global_allocator] 11 | static ALLOC: wee_alloc::WeeAlloc = wee_alloc::WeeAlloc::INIT; 12 | 13 | #[wasm_bindgen] 14 | pub fn phash(rgba: Vec, width: u32, height: u32, hash_size: Option) -> String { 15 | return phash_inner(rgba, width, height, hash_size.unwrap_or(16)); 16 | } 17 | 18 | fn phash_inner(rgba: Vec, width: u32, height: u32, hash_size: u32) -> String { 19 | let image: RgbaImage = ImageBuffer::from_raw(width, height, rgba).unwrap(); 20 | 21 | let hasher = HasherConfig::new() 22 | .hash_size(hash_size, hash_size) 23 | .resize_filter(FilterType::Lanczos3) 24 | .to_hasher(); 25 | 26 | let hash = hasher.hash_image(&image); 27 | 28 | return hex::encode(hash.as_bytes()); 29 | } 30 | -------------------------------------------------------------------------------- /services/wasm/src/utils.rs: -------------------------------------------------------------------------------- 1 | pub fn set_panic_hook() { 2 | // When the `console_error_panic_hook` feature is enabled, we can call the 3 | // `set_panic_hook` function at least once during initialization, and then 4 | // we will get better error messages if our code ever panics. 5 | // 6 | // For more details see 7 | // https://github.com/rustwasm/console_error_panic_hook#readme 8 | #[cfg(feature = "console_error_panic_hook")] 9 | console_error_panic_hook::set_once(); 10 | } 11 | -------------------------------------------------------------------------------- /specs/cotrans.yaml: -------------------------------------------------------------------------------- 1 | # TODO: OpenAPI spec here 2 | -------------------------------------------------------------------------------- /types/.gitignore: -------------------------------------------------------------------------------- 1 | /dist/ 2 | -------------------------------------------------------------------------------- /types/LICENSE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to -------------------------------------------------------------------------------- /types/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@cotrans/types", 3 | "type": "module", 4 | "version": "0.0.2", 5 | "private": false, 6 | "license": "Unlicense", 7 | "exports": { 8 | ".": { 9 | "require": "./dist/index.cjs", 10 | "import": "./dist/index.mjs" 11 | } 12 | }, 13 | "main": "./dist/index.cjs", 14 | "types": "./dist/index.d.ts", 15 | "files": [ 16 | "dist" 17 | ], 18 | "scripts": { 19 | "build": "unbuild" 20 | }, 21 | "devDependencies": { 22 | "unbuild": "^1.2.1" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /types/src/index.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/ban-types */ 2 | 3 | export interface TaskResult { 4 | /** 5 | * The URL to a mask layer to be applied on top of the original image. 6 | * 7 | * When the mask is empty, this value will be a URI of exactly:
8 | * `data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVQI12NgYAAAAAMAASDVlMcAAAAASUVORK5CYII=`
9 | * Which is a transparent 1x1 PNG image.
10 | * This image is only meant for backwards compatibility, and the client should skip masking in this case. 11 | * 12 | * @example 13 | * 'https://r2.cotrans.touhou.ai/mask/h4zjxwhjshl68pgfkwcad0w9.png' 14 | */ 15 | translation_mask: string 16 | } 17 | 18 | export interface QueryV1MessagePending { 19 | /** 20 | * Indicates that the task is still in the queue. 21 | */ 22 | type: 'pending' 23 | /** 24 | * The image's position in the queue, starts at 1. 25 | */ 26 | pos: number 27 | } 28 | 29 | export interface QueryV1MessageStatus { 30 | /** 31 | * Streamed status updates for the task. 32 | */ 33 | type: 'status' 34 | /** 35 | * The current status of the task.
36 | * Current possible values are listed in the type, but more may be added in the future. 37 | * 38 | * Values beginning with `error-` should be considered as errors. 39 | * Upon receiving such message, the client will be disconnected shortly after, 40 | * without receiving an `error` message. 41 | */ 42 | status: string & {} 43 | // cat manga_translator.py | sed -n -E "/server_send_status\(websocket, task\.id, |\.status\.status = |_report_progress\('/s/^.*'([^']+)'.*$/| '\1'/p" | sort | uniq 44 | | 'colorizing' 45 | | 'detection' 46 | | 'downloading' 47 | | 'downscaling' 48 | | 'error' 49 | | 'error-download' 50 | | 'error-lang' 51 | | 'error-translating' 52 | | 'error-upload' 53 | | 'finished' 54 | | 'inpainting' 55 | | 'mask-generation' 56 | | 'ocr' 57 | | 'pending' 58 | | 'preparing' 59 | | 'rendering' 60 | | 'saved' 61 | | 'saving' 62 | | 'skip-no-regions' 63 | | 'skip-no-text' 64 | | 'textline_merge' 65 | | 'translating' 66 | | 'uploading' 67 | | 'upscaling' 68 | } 69 | 70 | export interface QueryV1MessageResult { 71 | /** 72 | * Indicates that the task has finished and the result is available. 73 | */ 74 | type: 'result' 75 | /** 76 | * The result of the task. 77 | */ 78 | result: TaskResult 79 | } 80 | 81 | export interface QueryV1MessageError { 82 | /** 83 | * Indicates that the task has encountered an error. 84 | */ 85 | type: 'error' 86 | /** 87 | * A unique identifier given each occurrence of an error. 88 | * 89 | * @example 'tz4a98xxat96iws9zmbrgj3a' 90 | */ 91 | error_id?: string | null 92 | /** 93 | * An identifier for the error type.
94 | * Current possible values are listed in the type, but more may be added in the future. 95 | */ 96 | error?: string & {} | null 97 | | 'error-db' 98 | | 'error-worker' 99 | } 100 | 101 | export interface QueryV1MessageNotFound { 102 | /** 103 | * The given task id was not found in neither the queue nor the database. 104 | */ 105 | type: 'not_found' 106 | } 107 | 108 | /** 109 | * A message returned by the `/task/:id/status/v1` or `/task/:id/event/v1` endpoint. 110 | */ 111 | export type QueryV1Message = 112 | | QueryV1MessagePending 113 | | QueryV1MessageStatus 114 | | QueryV1MessageResult 115 | | QueryV1MessageError 116 | | QueryV1MessageNotFound 117 | 118 | /** 119 | * A message returned by the `/group/:group/event/v1` endpoint. 120 | */ 121 | export type GroupQueryV1Message = QueryV1Message & { 122 | /** 123 | * The id of the task. 124 | */ 125 | id: string 126 | } 127 | 128 | /** 129 | * Indicates the task has been successfully processed or was added to the queue. 130 | */ 131 | export interface UploadV1ResultSuccess { 132 | /** 133 | * The id of the task. 134 | */ 135 | id: string 136 | /** 137 | * The image's position in the queue, starts at 1. 138 | */ 139 | pos?: number | null 140 | /** 141 | * The result of the task, if it was already processed. 142 | */ 143 | result?: TaskResult | null 144 | } 145 | 146 | /** 147 | * Indicates the task could not be processed. 148 | */ 149 | export interface UploadV1ResultError { 150 | id: null 151 | /** 152 | * An identifier for the error type.
153 | * Current possible values are listed in the type, but more may be added in the future. 154 | * 155 | * `group-limit`: The task has been binded to too many groups.
156 | * `queue-full`: The queue is full.
157 | * `fetch-failed`: The url could not be fetched. (only for `url` uploads)
158 | * `file-too-large`: The file is too large. Currently the limit is 20MiB.
159 | * `resize-crash`: The resize process crashed, usually due to insufficient memory. 160 | * This can happen if the image has very large dimensions, or if the image is corrupted, 161 | * or if the image uses a color type outside of `RGB`, `RGBA`, `L`, `LA`. 162 | * The general guideline is to keep the image dimensions below 4096x4096.
163 | */ 164 | error?: string & {} | null 165 | | 'group-limit' 166 | | 'queue-full' 167 | | 'fetch-failed' 168 | | 'file-too-large' 169 | | 'resize-crash' 170 | } 171 | 172 | /** 173 | * The result of the `/task/upload/v1` endpoint. 174 | */ 175 | export type UploadV1Result = UploadV1ResultSuccess | UploadV1ResultError 176 | -------------------------------------------------------------------------------- /types/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "module": "ESNext", 5 | "target": "ESNext", 6 | "lib": ["ESNext"], 7 | "strict": true, 8 | "esModuleInterop": true, 9 | "skipLibCheck": true, 10 | "moduleResolution": "node", 11 | "jsx": "preserve", 12 | "resolveJsonModule": true, 13 | "strictNullChecks": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "paths": { 16 | "@/*": ["*"] 17 | } 18 | }, 19 | "include": ["**/*.ts"], 20 | "exclude": ["dist", "node_modules"] 21 | } 22 | -------------------------------------------------------------------------------- /uno.config.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/VoileLabs/cotrans/08992fa0d89e40f22477e90a361823942f2f8c86/uno.config.ts -------------------------------------------------------------------------------- /userscript/.gitignore: -------------------------------------------------------------------------------- 1 | /dist/ 2 | -------------------------------------------------------------------------------- /userscript/.vscode/i18n-ally-custom-framework.yml: -------------------------------------------------------------------------------- 1 | languageIds: 2 | - javascript 3 | - typescript 4 | - javascriptreact 5 | - typescriptreact 6 | 7 | usageMatchRegex: 8 | - '[^\w\d]t\([''"`]({key})[''"`]' 9 | 10 | refactorTemplates: 11 | - t('$1') 12 | 13 | monopoly: true 14 | -------------------------------------------------------------------------------- /userscript/.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "i18n-ally.localesPaths": [ 3 | "src/i18n" 4 | ], 5 | "i18n-ally.sourceLanguage": "zh-CN", 6 | "i18n-ally.keystyle": "nested" 7 | } 8 | -------------------------------------------------------------------------------- /userscript/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "imgtrans-userscript", 3 | "type": "module", 4 | "version": "0.8.0-bata.17", 5 | "private": true, 6 | "scripts": { 7 | "build": "rollup -c", 8 | "buildw": "rollup -cw", 9 | "test": "vitest --run", 10 | "testw": "vitest --watch", 11 | "lint": "eslint --fix ." 12 | }, 13 | "dependencies": { 14 | "@solid-primitives/event-listener": "^2.2.13", 15 | "@solid-primitives/map": "^0.4.6", 16 | "@solid-primitives/mutation-observer": "^1.1.13", 17 | "@solid-primitives/scheduled": "^1.4.0", 18 | "@solid-primitives/utils": "^6.2.0", 19 | "@twind/core": "^1.1.3", 20 | "@twind/preset-autoprefix": "^1.0.7", 21 | "@twind/preset-tailwind": "^1.1.4", 22 | "solid-js": "^1.7.8" 23 | }, 24 | "devDependencies": { 25 | "@babel/core": "^7.22.9", 26 | "@iconify-json/carbon": "^1.1.18", 27 | "@iconify-json/fluent": "^1.1.27", 28 | "@rollup/plugin-babel": "^6.0.3", 29 | "@rollup/plugin-commonjs": "^25.0.3", 30 | "@rollup/plugin-node-resolve": "^15.1.0", 31 | "@rollup/plugin-yaml": "^4.1.1", 32 | "@types/node": "^20.4.4", 33 | "@types/tampermonkey": "^4.0.10", 34 | "babel-preset-solid": "^1.7.7", 35 | "esbuild": "^0.18.15", 36 | "fast-glob": "^3.3.1", 37 | "happy-dom": "^10.5.2", 38 | "magic-string": "^0.30.1", 39 | "resolve": "^1.22.2", 40 | "rimraf": "^5.0.1", 41 | "rollup": "^3.26.3", 42 | "rollup-plugin-esbuild": "^5.0.0", 43 | "unplugin-icons": "^0.16.5", 44 | "vitest": "^0.33.0" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /userscript/rollup.config.js: -------------------------------------------------------------------------------- 1 | import path from 'node:path' 2 | import fs from 'node:fs' 3 | import resolve from 'resolve' 4 | import glob from 'fast-glob' 5 | import { defineConfig } from 'rollup' 6 | import { nodeResolve } from '@rollup/plugin-node-resolve' 7 | import commonjs from '@rollup/plugin-commonjs' 8 | import esbuild from 'rollup-plugin-esbuild' 9 | import icons from 'unplugin-icons/rollup' 10 | import yaml from '@rollup/plugin-yaml' 11 | import { babel } from '@rollup/plugin-babel' 12 | import info from './package.json' assert { type: 'json' } 13 | 14 | function fileExists(filePath) { 15 | try { 16 | return fs.statSync(filePath).isFile() 17 | } 18 | catch { 19 | return false 20 | } 21 | } 22 | 23 | function scanLicenses() { 24 | const pending = new Set(Object.keys(info.dependencies)) 25 | const res = [] 26 | for (const lib of pending) { 27 | const pkgJsonPath = resolve.sync(`${lib}/package.json`) 28 | const pkgPath = path.dirname(pkgJsonPath) 29 | const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8')) 30 | 31 | if ('dependencies' in pkgJson) { 32 | for (const dep of Object.keys(pkgJson.dependencies)) 33 | pending.add(dep) 34 | } 35 | 36 | if (fileExists(path.join(pkgPath, 'LICENSE'))) 37 | res.push(path.join(pkgPath, 'LICENSE')) 38 | } 39 | return res 40 | } 41 | 42 | function gennerateConfig(input, output, banner) { 43 | return defineConfig({ 44 | input, 45 | output: { 46 | dir: 'dist', 47 | entryFileNames: output, 48 | format: 'iife', 49 | generatedCode: 'es2015', 50 | banner: fs 51 | .readFileSync(banner, 'utf8') 52 | .replace(/{{version}}/g, info.version), 53 | footer: `\n${glob.sync([ 54 | '../LICENSE', 55 | 'src/**/LICENSE*', 56 | ...scanLicenses(), 57 | ], { onlyFiles: true }) 58 | .map(file => `/*\n${fs.readFileSync(file, 'utf-8').trimEnd()}\n*/`) 59 | .filter((v, i, a) => a.indexOf(v) === i) 60 | .join('\n\n')}`, 61 | }, 62 | plugins: [ 63 | nodeResolve(), 64 | commonjs(), 65 | yaml(), 66 | icons({ 67 | compiler: 'solid', 68 | }), 69 | esbuild({ 70 | charset: 'utf8', 71 | target: 'es2020', 72 | }), 73 | babel({ 74 | extensions: ['.js', '.jsx', '.ts', '.tsx'], 75 | babelHelpers: 'bundled', 76 | presets: [ 77 | ['babel-preset-solid', { 78 | delegateEvents: false, 79 | }], 80 | ], 81 | }), 82 | ], 83 | }) 84 | } 85 | 86 | export default [ 87 | gennerateConfig('src/main-regular.ts', 'imgtrans-userscript.user.js', 'src/banner-regular.js'), 88 | gennerateConfig('src/main-nsfw.ts', 'imgtrans-userscript-nsfw.user.js', 'src/banner-nsfw.js'), 89 | ] 90 | -------------------------------------------------------------------------------- /userscript/src/banner-nsfw.js: -------------------------------------------------------------------------------- 1 | // ==UserScript== 2 | // @name Cotrans Manga/Image Translator (NSFW Edition) 3 | // @name:zh-CN Cotrans 漫画/图片翻译器 (NSFW 版) 4 | // @namespace https://cotrans.touhou.ai/userscript/#nsfw 5 | // @version {{version}} 6 | // @description (WIP) Translate texts in images on E-Hentai(ExHentai) 7 | // @description:zh-CN (WIP) 一键翻译图片内文字,支持 E-Hentai(ExHentai) 8 | // @author QiroNT 9 | // @license GPL-3.0 10 | // @contributionURL https://ko-fi.com/voilelabs 11 | // @supportURL https://discord.gg/975FRV8ca6 12 | // @source https://cotrans.touhou.ai/ 13 | // @include https://e-hentai.org/* 14 | // @match http://e-hentai.org/* 15 | // @include https://exhentai.org/* 16 | // @match http://exhentai.org/* 17 | // @connect e-hentai.org 18 | // @connect exhentai.org 19 | // @connect exhentai55ld2wyap5juskbm67czulomrouspdacjamjeloj7ugjbsad.onion 20 | // @connect hath.network 21 | // @connect api.cotrans.touhou.ai 22 | // @connect r2.cotrans.touhou.ai 23 | // @connect cotrans-r2.moe.ci 24 | // @connect * 25 | // @grant GM.xmlHttpRequest 26 | // @grant GM_xmlhttpRequest 27 | // @grant GM.setValue 28 | // @grant GM_setValue 29 | // @grant GM.getValue 30 | // @grant GM_getValue 31 | // @grant GM.deleteValue 32 | // @grant GM_deleteValue 33 | // @grant GM.addValueChangeListener 34 | // @grant GM_addValueChangeListener 35 | // @grant GM.removeValueChangeListener 36 | // @grant GM_removeValueChangeListener 37 | // @grant window.onurlchange 38 | // @run-at document-idle 39 | // ==/UserScript== 40 | 41 | /* eslint-disable no-undef, unused-imports/no-unused-vars */ 42 | const VERSION = '{{version}}' 43 | const EDITION = 'nsfw' 44 | let GMP 45 | { 46 | // polyfill functions 47 | const GMPFunctionMap = { 48 | xmlHttpRequest: typeof GM_xmlhttpRequest !== 'undefined' ? GM_xmlhttpRequest : undefined, 49 | setValue: typeof GM_setValue !== 'undefined' ? GM_setValue : undefined, 50 | getValue: typeof GM_getValue !== 'undefined' ? GM_getValue : undefined, 51 | deleteValue: typeof GM_deleteValue !== 'undefined' ? GM_deleteValue : undefined, 52 | addValueChangeListener: typeof GM_addValueChangeListener !== 'undefined' ? GM_addValueChangeListener : undefined, 53 | removeValueChangeListener: typeof GM_removeValueChangeListener !== 'undefined' ? GM_removeValueChangeListener : undefined, 54 | } 55 | const xmlHttpRequest = GM.xmlHttpRequest.bind(GM) || GMPFunctionMap.xmlHttpRequest 56 | GMP = new Proxy(GM, { 57 | get(target, prop) { 58 | if (prop === 'xmlHttpRequest') { 59 | return (context) => { 60 | return new Promise((resolve, reject) => { 61 | xmlHttpRequest({ 62 | ...context, 63 | onload(event) { 64 | context.onload?.() 65 | resolve(event) 66 | }, 67 | onerror(event) { 68 | context.onerror?.() 69 | reject(event) 70 | }, 71 | }) 72 | }) 73 | } 74 | } 75 | if (prop in target) { 76 | const v = target[prop] 77 | return typeof v === 'function' ? v.bind(target) : v 78 | } 79 | if (prop in GMPFunctionMap && typeof GMPFunctionMap[prop] === 'function') 80 | return GMPFunctionMap[prop] 81 | 82 | console.error(`[Cotrans Manga Translator] GM.${prop} isn't supported in your userscript engine and it's required by this script. This may lead to unexpected behavior.`) 83 | }, 84 | }) 85 | } 86 | -------------------------------------------------------------------------------- /userscript/src/banner-regular.js: -------------------------------------------------------------------------------- 1 | // ==UserScript== 2 | // @name Cotrans Manga/Image Translator (Regular Edition) 3 | // @name:zh-CN Cotrans 漫画/图片翻译器 (常规版) 4 | // @namespace https://cotrans.touhou.ai/userscript/#regular 5 | // @version {{version}} 6 | // @description (WIP) Translate texts in images on Pixiv, Twitter, Misskey, Calckey 7 | // @description:zh-CN (WIP) 一键翻译图片内文字,支持 Pixiv, Twitter, Misskey, Calckey 8 | // @author QiroNT 9 | // @license GPL-3.0 10 | // @contributionURL https://ko-fi.com/voilelabs 11 | // @supportURL https://discord.gg/975FRV8ca6 12 | // @source https://cotrans.touhou.ai/ 13 | // @include https://www.pixiv.net/* 14 | // @match https://www.pixiv.net/* 15 | // @include https://twitter.com/* 16 | // @match https://twitter.com/* 17 | // @include https://misskey.io/* 18 | // @match https://misskey.io/* 19 | // @include https://calckey.social/* 20 | // @match https://calckey.social/* 21 | // @include https://* 22 | // @match https://* 23 | // @connect pixiv.net 24 | // @connect pximg.net 25 | // @connect twitter.com 26 | // @connect twimg.com 27 | // @connect misskey.io 28 | // @connect misskeyusercontent.com 29 | // @connect s3.arkjp.net 30 | // @connect nfs.pub 31 | // @connect calckey.social 32 | // @connect backblazeb2.com 33 | // @connect dvd.moe 34 | // @connect api.cotrans.touhou.ai 35 | // @connect r2.cotrans.touhou.ai 36 | // @connect cotrans-r2.moe.ci 37 | // @connect * 38 | // @grant GM.xmlHttpRequest 39 | // @grant GM_xmlhttpRequest 40 | // @grant GM.setValue 41 | // @grant GM_setValue 42 | // @grant GM.getValue 43 | // @grant GM_getValue 44 | // @grant GM.deleteValue 45 | // @grant GM_deleteValue 46 | // @grant GM.addValueChangeListener 47 | // @grant GM_addValueChangeListener 48 | // @grant GM.removeValueChangeListener 49 | // @grant GM_removeValueChangeListener 50 | // @grant window.onurlchange 51 | // @run-at document-idle 52 | // ==/UserScript== 53 | 54 | /* eslint-disable no-undef, unused-imports/no-unused-vars */ 55 | const VERSION = '{{version}}' 56 | const EDITION = 'regular' 57 | let GMP 58 | { 59 | // polyfill functions 60 | const GMPFunctionMap = { 61 | xmlHttpRequest: typeof GM_xmlhttpRequest !== 'undefined' ? GM_xmlhttpRequest : undefined, 62 | setValue: typeof GM_setValue !== 'undefined' ? GM_setValue : undefined, 63 | getValue: typeof GM_getValue !== 'undefined' ? GM_getValue : undefined, 64 | deleteValue: typeof GM_deleteValue !== 'undefined' ? GM_deleteValue : undefined, 65 | addValueChangeListener: typeof GM_addValueChangeListener !== 'undefined' ? GM_addValueChangeListener : undefined, 66 | removeValueChangeListener: typeof GM_removeValueChangeListener !== 'undefined' ? GM_removeValueChangeListener : undefined, 67 | } 68 | const xmlHttpRequest = GM.xmlHttpRequest.bind(GM) || GMPFunctionMap.xmlHttpRequest 69 | GMP = new Proxy(GM, { 70 | get(target, prop) { 71 | if (prop === 'xmlHttpRequest') { 72 | return (context) => { 73 | return new Promise((resolve, reject) => { 74 | xmlHttpRequest({ 75 | ...context, 76 | onload(event) { 77 | context.onload?.() 78 | resolve(event) 79 | }, 80 | onerror(event) { 81 | context.onerror?.() 82 | reject(event) 83 | }, 84 | }) 85 | }) 86 | } 87 | } 88 | if (prop in target) { 89 | const v = target[prop] 90 | return typeof v === 'function' ? v.bind(target) : v 91 | } 92 | if (prop in GMPFunctionMap && typeof GMPFunctionMap[prop] === 'function') 93 | return GMPFunctionMap[prop] 94 | 95 | console.error(`[Cotrans Manga Translator] GM.${prop} isn't supported in your userscript engine and it's required by this script. This may lead to unexpected behavior.`) 96 | }, 97 | }) 98 | } 99 | -------------------------------------------------------------------------------- /userscript/src/eHentai/gallery.ts: -------------------------------------------------------------------------------- 1 | import type { Translator, TranslatorInstance } from '../main' 2 | 3 | function mount(): TranslatorInstance { 4 | const galleryId = window.location.pathname.match(/\/g\/(\d+)/)?.[1] 5 | if (!galleryId) 6 | return {} 7 | 8 | return {} 9 | } 10 | 11 | const translator: Translator = { 12 | match(url) { 13 | // https://e-hentai.org/g// 14 | // https://exhentai.org/g// 15 | // https://exhentai55ld2wyap5juskbm67czulomrouspdacjamjeloj7ugjbsad.onion/g// 16 | if (!url.hostname.endsWith('e-hentai.org') && !url.hostname.endsWith('exhentai.org') 17 | && !url.hostname.endsWith('exhentai55ld2wyap5juskbm67czulomrouspdacjamjeloj7ugjbsad.onion')) 18 | return false 19 | if (!url.pathname.startsWith('/g/')) 20 | return false 21 | return true 22 | }, 23 | mount, 24 | } 25 | 26 | export default translator 27 | -------------------------------------------------------------------------------- /userscript/src/eHentai/page.ts: -------------------------------------------------------------------------------- 1 | import type { Translator, TranslatorInstance } from '../main' 2 | 3 | function mount(): TranslatorInstance { 4 | return {} 5 | } 6 | 7 | const translator: Translator = { 8 | match(url) { 9 | // https://e-hentai.org/s//- 10 | // https://exhentai.org/s//- 11 | // https://exhentai55ld2wyap5juskbm67czulomrouspdacjamjeloj7ugjbsad.onion/s//- 12 | return false 13 | }, 14 | mount, 15 | } 16 | 17 | export default translator 18 | -------------------------------------------------------------------------------- /userscript/src/eHentai/settings.ts: -------------------------------------------------------------------------------- 1 | import type { SettingsInjector, SettingsInjectorInstance } from '../main' 2 | 3 | function mount(): SettingsInjectorInstance { 4 | return {} 5 | } 6 | 7 | const settingsInjector: SettingsInjector = { 8 | match(url) { 9 | return false 10 | }, 11 | mount, 12 | } 13 | 14 | export default settingsInjector 15 | -------------------------------------------------------------------------------- /userscript/src/i18n/en-US.yml: -------------------------------------------------------------------------------- 1 | common: 2 | source: 3 | download-image: Downloading original image 4 | download-image-progress: Downloading original image ({progress}) 5 | download-image-error: Error during original image download 6 | client: 7 | submit: Submitting translation 8 | submit-progress: Submitting translation ({progress}) 9 | submit-final: Waiting for image transpile 10 | submit-error: Error during translation submission 11 | download-image: Downloading translated image 12 | download-image-progress: Downloading translated image ({progress}) 13 | download-image-error: Error during translated image download 14 | resize: Resizing image 15 | merging: Merging layers 16 | status: 17 | default: Unknown status 18 | pending: Pending 19 | pending-pos: Pending, {pos} in queue 20 | downloading: Transferring image 21 | preparing: Waiting for idle window 22 | colorizing: Colorizing 23 | upscaling: Upscaling 24 | downscaling: Downscaling 25 | detection: Detecting text 26 | ocr: Scanning text 27 | textline_merge: Merging text lines 28 | mask-generation: Generating mask 29 | inpainting: Inpainting 30 | translating: Translating 31 | rendering: Rendering 32 | finished: Finishing 33 | saved: Saved 34 | saving: Saving result 35 | uploading: Transferring result 36 | error: Error during translation 37 | error-download: Image transfer failed 38 | error-upload: Result transfer failed 39 | error-lang: The target language is not supported by the chosen translator 40 | error-translating: Did not get any text back from the text translation service 41 | skip-no-regions: No text regions detected in the image 42 | skip-no-text: No text detected in the image 43 | error-with-id: 'Error during translation (ID: {id})' 44 | control: 45 | translate: Translate 46 | batch: Translate all ({count}) 47 | reset: Reset 48 | batch: 49 | progress: Translating ({count}/{total} finished) 50 | finish: Translation finished 51 | error: Translation finished with errors 52 | settings: 53 | detection-resolution: Text detection resolution 54 | render-text-orientation: Render text orientation 55 | render-text-orientation-options: 56 | auto: Follow source 57 | horizontal: Horizontal only 58 | vertical: Vertical only 59 | reset: Reset Settings 60 | target-language: Translate target language 61 | target-language-options: 62 | auto: Follow website 63 | text-detector: Text detector 64 | text-detector-options: 65 | default: Default 66 | title: Cotrans Manga Translator Settings 67 | translator: Translator 68 | script-language: Userscript language 69 | script-language-options: 70 | auto: Follow website language 71 | inline-options-title: Current Settings 72 | detection-resolution-desc: >- 73 | The resolution used to scan texts on an image, higher value are better 74 | suited for smaller texts. 75 | script-language-desc: Language of this userscript. 76 | render-text-orientation-desc: Overwrite the orientation of texts rendered in the translated image. 77 | target-language-desc: The language that images are translated to. 78 | text-detector-desc: The detector used to scan texts in an image. 79 | translator-desc: The translate service used to translate texts. 80 | translator-options: 81 | none: None (remove texts) 82 | keep-instances-options: 83 | until-reload: Until page reload 84 | until-navigate: Until next navigation 85 | keep-instances: Keep translation instances 86 | keep-instances-desc: >- 87 | How long before a translation instance is disposed. A translation instance 88 | includes the translation state of an image, that is, whether the image is 89 | translated or not, and the translation result. Keeping more translation 90 | instances will result in more memory consumption. 91 | force-retry: Force retry (ignore cache) 92 | sponsor: 93 | text: If you find this script helpful, please consider supporting us! 94 | -------------------------------------------------------------------------------- /userscript/src/i18n/index.ts: -------------------------------------------------------------------------------- 1 | import type { MaybeAccessor } from '@solid-primitives/utils' 2 | import { access } from '@solid-primitives/utils' 3 | import type { Accessor } from 'solid-js' 4 | import { createMemo, createSignal } from 'solid-js' 5 | import { scriptLang } from '../utils/storage' 6 | 7 | import zhCN from './zh-CN.yml' 8 | import enUS from './en-US.yml' 9 | 10 | const messages: Record = { 11 | 'zh-CN': zhCN, 12 | 'en-US': enUS, 13 | } 14 | 15 | function tryMatchLang(lang: string): string { 16 | if (lang.startsWith('zh')) 17 | return 'zh-CN' 18 | if (lang.startsWith('en')) 19 | return 'en-US' 20 | return 'en-US' 21 | } 22 | 23 | export const [realLang, setRealLang] = createSignal(navigator.language) 24 | export const lang = createMemo(() => scriptLang() || tryMatchLang(realLang())) 25 | 26 | export function t(key_: MaybeAccessor, props: MaybeAccessor>> = {}): Accessor { 27 | return createMemo(() => { 28 | const key = access(key_) 29 | const segments = key.split('.') 30 | const msg: string = segments.reduce((obj, k) => obj[k], messages[lang()]) ?? segments.reduce((obj, k) => obj[k], messages['zh-CN']) 31 | if (!msg) 32 | return key 33 | return msg.replace(/\{([^}]+)\}/g, (_, k) => String(access(access(props)[k])) ?? '') 34 | }) 35 | } 36 | 37 | let langEL: HTMLHtmlElement | undefined 38 | let langObserver: MutationObserver | undefined 39 | 40 | export function changeLangEl(el: HTMLHtmlElement) { 41 | if (langEL === el) 42 | return 43 | 44 | if (langObserver) 45 | langObserver.disconnect() 46 | 47 | langObserver = new MutationObserver((mutations) => { 48 | for (const mutation of mutations) { 49 | if (mutation.type === 'attributes' && mutation.attributeName === 'lang') { 50 | const target = mutation.target as HTMLHtmlElement 51 | if (target.lang) 52 | setRealLang(target.lang) 53 | break 54 | } 55 | } 56 | }) 57 | langObserver.observe(el, { attributes: true }) 58 | 59 | langEL = el 60 | setRealLang(el.lang) 61 | } 62 | 63 | export function BCP47ToISO639(code: string): string { 64 | try { 65 | const lo = new Intl.Locale(code) 66 | switch (lo.language) { 67 | case 'zh': { 68 | switch (lo.script) { 69 | case 'Hans': 70 | return 'CHS' 71 | case 'Hant': 72 | return 'CHT' 73 | } 74 | switch (lo.region) { 75 | case 'CN': 76 | return 'CHS' 77 | case 'HK': 78 | case 'TW': 79 | return 'CHT' 80 | } 81 | return 'CHS' 82 | } 83 | case 'ja': 84 | return 'JPN' 85 | case 'en': 86 | return 'ENG' 87 | case 'ko': 88 | return 'KOR' 89 | case 'vi': 90 | return 'VIE' 91 | case 'cs': 92 | return 'CSY' 93 | case 'nl': 94 | return 'NLD' 95 | case 'fr': 96 | return 'FRA' 97 | case 'de': 98 | return 'DEU' 99 | case 'hu': 100 | return 'HUN' 101 | case 'it': 102 | return 'ITA' 103 | case 'pl': 104 | return 'PLK' 105 | case 'pt': 106 | return 'PTB' 107 | case 'ro': 108 | return 'ROM' 109 | case 'ru': 110 | return 'RUS' 111 | case 'es': 112 | return 'ESP' 113 | case 'tr': 114 | return 'TRK' 115 | case 'uk': 116 | return 'UKR' 117 | } 118 | return 'ENG' 119 | } 120 | catch (e) { 121 | return 'ENG' 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /userscript/src/i18n/zh-CN.yml: -------------------------------------------------------------------------------- 1 | common: 2 | source: 3 | download-image: 正在拉取原图 4 | download-image-progress: 正在拉取原图({progress}) 5 | download-image-error: 拉取原图出错 6 | client: 7 | submit: 正在提交翻译 8 | submit-progress: 正在提交翻译({progress}) 9 | submit-final: 等待图片转存 10 | submit-error: 提交翻译出错 11 | download-image: 正在下载图片 12 | download-image-progress: 正在下载图片({progress}) 13 | download-image-error: 下载图片出错 14 | resize: 正在缩放图片 15 | merging: 正在合并图层 16 | status: 17 | default: 未知状态 18 | pending: 正在等待 19 | pending-pos: 正在等待,列队还有 {pos} 张图片 20 | downloading: 正在传输图片 21 | preparing: 等待空闲窗口 22 | colorizing: 正在上色 23 | upscaling: 正在放大图片 24 | downscaling: 正在缩小图片 25 | detection: 正在检测文本 26 | ocr: 正在识别文本 27 | textline_merge: 正在整合文本 28 | mask-generation: 正在生成文本掩码 29 | inpainting: 正在修补图片 30 | translating: 正在翻译文本 31 | rendering: 正在渲染文本 32 | finished: 正在整理结果 33 | saved: 保存结果 34 | saving: 正在保存结果 35 | uploading: 正在传输结果 36 | error: 翻译出错 37 | error-download: 传输图片失败 38 | error-upload: 传输结果失败 39 | error-lang: 你选择的翻译服务不支持你选择的语言 40 | error-translating: 翻译服务没有返回任何文本 41 | skip-no-regions: 图片中没有检测到文本区域 42 | skip-no-text: 图片中没有检测到文本 43 | error-with-id: '翻译出错 (ID: {id})' 44 | control: 45 | translate: 翻译 46 | batch: 翻译全部 ({count}) 47 | reset: 还原 48 | batch: 49 | progress: 翻译中 ({count}/{total}) 50 | finish: 翻译完成 51 | error: 翻译完成(有失败) 52 | settings: 53 | title: Cotrans 图片翻译器设置 54 | inline-options-title: 设置当前翻译 55 | detection-resolution: 文本扫描清晰度 56 | text-detector: 文本扫描器 57 | text-detector-options: 58 | default: 默认 59 | translator: 翻译服务 60 | render-text-orientation: 渲染字体方向 61 | render-text-orientation-options: 62 | auto: 跟随原文本 63 | horizontal: 仅限水平 64 | vertical: 仅限垂直 65 | target-language: 翻译语言 66 | target-language-options: 67 | auto: 跟随网页语言 68 | script-language: 用户脚本语言 69 | script-language-options: 70 | auto: 跟随网页语言 71 | reset: 重置所有设置 72 | detection-resolution-desc: 设置检测图片文本所用的清晰度,小文字适合使用更高的清晰度。 73 | text-detector-desc: 设置使用的文本扫描器。 74 | translator-desc: 设置翻译图片所用的翻译服务。 75 | render-text-orientation-desc: 设置嵌字的文本方向。 76 | target-language-desc: 设置图片翻译后的语言。 77 | script-language-desc: 设置此用户脚本的语言。 78 | translator-options: 79 | none: None (删除文字) 80 | keep-instances-options: 81 | until-reload: 直到页面刷新 82 | until-navigate: 直到下次跳转 83 | keep-instances: 保留翻译进度 84 | keep-instances-desc: >- 85 | 设置翻译进度的保留时间。 翻译进度即图片的翻译状态和翻译结果。 86 | 保留更多的翻译进度会占用更多的内存。 87 | force-retry: 强制重试 (忽略缓存) 88 | sponsor: 89 | text: 制作不易,请考虑赞助我们! 90 | -------------------------------------------------------------------------------- /userscript/src/main-nsfw.ts: -------------------------------------------------------------------------------- 1 | import eHentaiGallery from './eHentai/gallery' 2 | import eHentaiPage from './eHentai/page' 3 | import eHentaiSettings from './eHentai/settings' 4 | import { start } from './main' 5 | 6 | start( 7 | [ 8 | eHentaiGallery, 9 | eHentaiPage, 10 | ], 11 | [ 12 | eHentaiSettings, 13 | ], 14 | ) 15 | -------------------------------------------------------------------------------- /userscript/src/main-regular.ts: -------------------------------------------------------------------------------- 1 | import { start } from './main' 2 | import pixiv from './pixiv' 3 | import pixivSettings from './pixiv/settings' 4 | import twitter from './twitter' 5 | import twitterSettings from './twitter/settings' 6 | import misskey from './misskey' 7 | 8 | start( 9 | [ 10 | pixiv, 11 | twitter, 12 | misskey, 13 | ], 14 | [ 15 | pixivSettings, 16 | twitterSettings, 17 | ], 18 | ) 19 | -------------------------------------------------------------------------------- /userscript/src/main.ts: -------------------------------------------------------------------------------- 1 | import { throttle } from '@solid-primitives/scheduled' 2 | import { createRoot } from 'solid-js' 3 | import { DelegatedEvents } from 'solid-js/web' 4 | import { changeLangEl } from './i18n' 5 | import { storageReady } from './utils/storage' 6 | 7 | // https://github.com/solidjs/solid/issues/334#issuecomment-773807937 8 | DelegatedEvents.clear() 9 | 10 | export interface TranslatorInstance { 11 | canKeep?: (url: string) => unknown 12 | onURLChange?: (url: string) => unknown 13 | } 14 | export interface Translator { 15 | match: (url: URL) => unknown 16 | mount: () => TranslatorInstance 17 | } 18 | 19 | export interface SettingsInjectorInstance { 20 | canKeep?: (url: string) => unknown 21 | onURLChange?: (url: string) => unknown 22 | } 23 | export interface SettingsInjector { 24 | match: (url: URL) => unknown 25 | mount: () => SettingsInjectorInstance 26 | } 27 | 28 | type ScopedInstance = Omit & { 29 | dispose: () => void 30 | } 31 | 32 | function createScopedInstance(cb: () => T): ScopedInstance { 33 | return createRoot((dispose) => { 34 | const instance = cb() 35 | return { 36 | ...instance, 37 | dispose, 38 | } 39 | }) 40 | } 41 | 42 | let currentURL: string | undefined 43 | let translator: ScopedInstance | undefined 44 | let settingsInjector: ScopedInstance | undefined 45 | 46 | export async function start(translators: Translator[], settingsInjectors: SettingsInjector[]) { 47 | await storageReady 48 | 49 | async function onUpdate() { 50 | await new Promise(resolve => (queueMicrotask ?? setTimeout)(resolve)) 51 | 52 | if (currentURL !== location.href) { 53 | currentURL = location.href 54 | 55 | // there is a navigation in the page 56 | 57 | // update i18n element 58 | changeLangEl(document.documentElement as HTMLHtmlElement) 59 | 60 | // update translator 61 | // only if the translator needs to be updated 62 | if (translator?.canKeep?.(currentURL)) { 63 | translator.onURLChange?.(currentURL) 64 | } 65 | else { 66 | // unmount previous translator 67 | translator?.dispose() 68 | translator = undefined 69 | 70 | // check if the page is a image page 71 | const url = new URL(location.href) 72 | 73 | // find the first translator that matches the url 74 | const matched = translators.find(t => t.match(url)) 75 | if (matched) 76 | translator = createScopedInstance(matched.mount) 77 | } 78 | 79 | /* update settings page */ 80 | if (settingsInjector?.canKeep?.(currentURL)) { 81 | settingsInjector.onURLChange?.(currentURL) 82 | } 83 | else { 84 | // unmount previous settings injector 85 | settingsInjector?.dispose() 86 | settingsInjector = undefined 87 | 88 | // check if the page is a settings page 89 | const url = new URL(location.href) 90 | 91 | // find the first settings injector that matches the url 92 | const matched = settingsInjectors.find(t => t.match(url)) 93 | if (matched) 94 | settingsInjector = createScopedInstance(matched.mount) 95 | } 96 | } 97 | } 98 | 99 | if (window.onurlchange === null) { 100 | window.addEventListener('urlchange', onUpdate) 101 | 102 | // FIXME temporary fix for TM not firing urlchange event on hash change 103 | const pushState = history.pushState 104 | window.history.pushState = function () { 105 | // eslint-disable-next-line prefer-rest-params 106 | pushState.apply(this, arguments as any) 107 | // eslint-disable-next-line prefer-rest-params 108 | if (typeof arguments[2] === 'string' && arguments[2].startsWith('#')) 109 | onUpdate() 110 | } 111 | } 112 | else { 113 | const installObserver = new MutationObserver(throttle(onUpdate, 200)) 114 | installObserver.observe(document.body, { childList: true, subtree: true }) 115 | } 116 | onUpdate() 117 | } 118 | -------------------------------------------------------------------------------- /userscript/src/pixiv/settings.tsx: -------------------------------------------------------------------------------- 1 | import { onCleanup } from 'solid-js' 2 | import { render } from 'solid-js/web' 3 | import { tw } from '../utils/twind' 4 | import { t } from '../i18n' 5 | import type { SettingsInjector, SettingsInjectorInstance } from '../main' 6 | import { Settings } from '../settings' 7 | 8 | function mount(): SettingsInjectorInstance { 9 | const wrapper = document.getElementById('wrapper') 10 | if (!wrapper) 11 | return {} 12 | 13 | const adFooter = wrapper.querySelector('.ad-footer') 14 | if (!adFooter) 15 | return {} 16 | 17 | const settingsContainer = document.createElement('div') 18 | onCleanup(() => { 19 | settingsContainer.remove() 20 | }) 21 | 22 | const disposeSettings = render(() => ( 23 |
24 |

25 | {t('settings.title')()} 26 |

27 |
28 | 35 |
36 |
37 | ), settingsContainer) 38 | onCleanup(disposeSettings) 39 | 40 | wrapper.insertBefore(settingsContainer, adFooter) 41 | 42 | return {} 43 | } 44 | 45 | const settingsInjector: SettingsInjector = { 46 | match(url) { 47 | // https://www.pixiv.net/setting_user.php 48 | return url.hostname.endsWith('pixiv.net') && url.pathname.match(/\/setting_user\.php/) 49 | }, 50 | mount, 51 | } 52 | 53 | export default settingsInjector 54 | -------------------------------------------------------------------------------- /userscript/src/settings/index.tsx: -------------------------------------------------------------------------------- 1 | import type { Accessor, Component, JSX } from 'solid-js' 2 | import { For, Show } from 'solid-js' 3 | import { tw } from '../utils/twind' 4 | import { t } from '../i18n' 5 | import { 6 | detectionResolution, 7 | keepInstances, 8 | renderTextOrientation, 9 | scriptLang, 10 | setDetectionResolution, 11 | setKeepInstances, 12 | setRenderTextOrientation, 13 | setScriptLang, 14 | setTargetLang, 15 | setTextDetector, 16 | setTranslatorService, 17 | targetLang, 18 | textDetector, 19 | translatorService, 20 | } from '../utils/storage' 21 | 22 | type OptionsMap = Record> 23 | 24 | export const detectResOptionsMap = { 25 | S: () => '1024px', 26 | M: () => '1536px', 27 | L: () => '2048px', 28 | X: () => '2560px', 29 | } satisfies OptionsMap 30 | export const detectResOptions = Object.keys(detectResOptionsMap) 31 | export type DetectResOption = keyof typeof detectResOptionsMap 32 | 33 | export const renderTextDirOptionsMap = { 34 | auto: t('settings.render-text-orientation-options.auto'), 35 | h: t('settings.render-text-orientation-options.horizontal'), 36 | v: t('settings.render-text-orientation-options.vertical'), 37 | } satisfies OptionsMap 38 | export const renderTextDirOptions = Object.keys(renderTextDirOptionsMap) 39 | export type RenderTextDirOption = keyof typeof renderTextDirOptionsMap 40 | 41 | export const textDetectorOptionsMap = { 42 | default: t('settings.text-detector-options.default'), 43 | ctd: () => 'Comic Text Detector', 44 | } satisfies OptionsMap 45 | export const textDetectorOptions = Object.keys(textDetectorOptionsMap) 46 | export type TextDetectorOption = keyof typeof textDetectorOptionsMap 47 | 48 | export const translatorOptionsMap = { 49 | 'gpt3.5': () => 'GPT-3.5', 50 | 'youdao': () => 'Youdao', 51 | 'baidu': () => 'Baidu', 52 | 'google': () => 'Google', 53 | 'deepl': () => 'DeepL', 54 | 'papago': () => 'Papago', 55 | 'offline': () => 'Sugoi / NLLB', 56 | 'none': t('settings.translator-options.none'), 57 | } satisfies OptionsMap 58 | export const translatorOptions = Object.keys(translatorOptionsMap) 59 | export type TranslatorOption = keyof typeof translatorOptionsMap 60 | 61 | export const targetLangOptionsMap = { 62 | '': t('settings.target-language-options.auto'), 63 | 'CHS': () => '简体中文', 64 | 'CHT': () => '繁體中文', 65 | 'JPN': () => '日本語', 66 | 'ENG': () => 'English', 67 | 'KOR': () => '한국어', 68 | 'VIN': () => 'Tiếng Việt', 69 | 'CSY': () => 'čeština', 70 | 'NLD': () => 'Nederlands', 71 | 'FRA': () => 'français', 72 | 'DEU': () => 'Deutsch', 73 | 'HUN': () => 'magyar nyelv', 74 | 'ITA': () => 'italiano', 75 | 'PLK': () => 'polski', 76 | 'PTB': () => 'português', 77 | 'ROM': () => 'limba română', 78 | 'RUS': () => 'русский язык', 79 | 'UKR': () => 'українська мова', 80 | 'ESP': () => 'español', 81 | 'TRK': () => 'Türk dili', 82 | } satisfies OptionsMap 83 | export const targetLangOptions = Object.keys(targetLangOptionsMap) 84 | export type TargetLangOption = keyof typeof targetLangOptionsMap 85 | 86 | export const scriptLangOptionsMap = { 87 | '': t('settings.script-language-options.auto'), 88 | 'zh-CN': () => '简体中文', 89 | 'en-US': () => 'English', 90 | } satisfies OptionsMap 91 | export const scriptLangOptions = Object.keys(scriptLangOptionsMap) 92 | export type ScriptLangOption = keyof typeof scriptLangOptionsMap 93 | 94 | export const keepInstancesOptionsMap = { 95 | 'until-reload': t('settings.keep-instances-options.until-reload'), 96 | 'until-navigate': t('settings.keep-instances-options.until-navigate'), 97 | } satisfies OptionsMap 98 | export const keepInstancesOptions = Object.keys(keepInstancesOptionsMap) 99 | export type KeepInstancesOption = keyof typeof keepInstancesOptionsMap 100 | 101 | export const Settings: Component<{ 102 | itemOrientation?: 'vertical' | 'horizontal' 103 | textStyle?: JSX.HTMLAttributes['style'] 104 | }> = (props) => { 105 | const itemOrientation = () => props.itemOrientation ?? 'vertical' 106 | const textStyle = () => props.textStyle ?? {} 107 | 108 | return ( 109 |
110 | {/* Meta */} 111 |
{EDITION} edition, v{VERSION}
112 | {/* Sponsor */} 113 |
114 | {t('sponsor.text')()} 115 |
116 |
117 | {([name, url]) => ( 122 | <> 123 | {' '} 124 | {name} 130 | 131 | )} 132 |
133 | {/* Settings */} 134 | {([title, opt, setOpt, optMap, desc]) => ( 171 |
172 |
{title()}
173 |
174 | 183 | 184 |
{desc()}
185 |
186 |
187 |
188 | )}
189 | {/* Reset */} 190 |
191 | 204 |
205 |
206 | ) 207 | } 208 | -------------------------------------------------------------------------------- /userscript/src/shims.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | 3 | declare module '*.yaml' { 4 | const data: any 5 | export default data 6 | } 7 | declare module '*.yml' { 8 | const data: any 9 | export default data 10 | } 11 | 12 | declare const GMP: typeof GM 13 | declare const VERSION: string 14 | declare const EDITION: 'regular' | 'nsfw' 15 | -------------------------------------------------------------------------------- /userscript/src/twitter/settings.tsx: -------------------------------------------------------------------------------- 1 | import { createMutationObserver } from '@solid-primitives/mutation-observer' 2 | import { throttle } from '@solid-primitives/scheduled' 3 | import { onCleanup } from 'solid-js' 4 | import { render } from 'solid-js/web' 5 | import { tw } from '../utils/twind' 6 | import { t } from '../i18n' 7 | import type { SettingsInjector, SettingsInjectorInstance } from '../main' 8 | import { Settings } from '../settings' 9 | 10 | function mount(): SettingsInjectorInstance { 11 | let settingsTab: HTMLElement | undefined 12 | let disposeText: (() => void) | undefined 13 | const checkTab = () => { 14 | const tablist = document.querySelector('[role="tablist"]') 15 | || document.querySelector('[data-testid="loggedOutPrivacySection"]') 16 | if (!tablist) { 17 | if (disposeText) { 18 | disposeText() 19 | disposeText = undefined 20 | } 21 | return 22 | } 23 | 24 | if (tablist.querySelector(`div[data-imgtrans-settings-${EDITION}]`)) 25 | return 26 | 27 | const inactiveRefrenceEl = Array.from(tablist.children) 28 | .find(el => el.children.length < 2 && el.querySelector('a')) 29 | if (!inactiveRefrenceEl) 30 | return 31 | 32 | settingsTab = inactiveRefrenceEl.cloneNode(true) as HTMLElement 33 | settingsTab.setAttribute(`data-imgtrans-settings-${EDITION}`, 'true') 34 | 35 | const textEl = settingsTab.querySelector('span') 36 | if (textEl) { 37 | while (textEl.firstChild) 38 | textEl.removeChild(textEl.firstChild) 39 | disposeText = render(() => t('settings.title')(), textEl) 40 | onCleanup(disposeText) 41 | } 42 | 43 | const linkEl = settingsTab.querySelector('a') 44 | if (linkEl) 45 | linkEl.href = `/settings/__imgtrans_${EDITION}` 46 | 47 | tablist.appendChild(settingsTab) 48 | } 49 | 50 | let disposeSettings: (() => void) | undefined 51 | const checkSettings = () => { 52 | const section = document.querySelector('[data-testid="error-detail"]') 53 | ?.parentElement?.parentElement as HTMLElement | null 54 | if (!section?.querySelector(`[data-imgtrans-settings-${EDITION}-section]`)) { 55 | if (disposeSettings) { 56 | disposeSettings() 57 | disposeSettings = undefined 58 | } 59 | if (!section) 60 | return 61 | } 62 | 63 | const title = `${t('settings.title')()} / Twitter` 64 | if (document.title !== title) 65 | document.title = title 66 | 67 | if (disposeSettings) 68 | return 69 | 70 | const errorPage = section.firstChild! as HTMLElement 71 | errorPage.style.display = 'none' 72 | 73 | const settingsContainer = document.createElement('div') 74 | settingsContainer.setAttribute(`data-imgtrans-settings-${EDITION}-section`, 'true') 75 | section.appendChild(settingsContainer) 76 | const disposeSettingsApp = render(() => { 77 | onCleanup(() => { 78 | errorPage.style.display = '' 79 | }) 80 | 81 | return ( 82 | // r-37j5jr: twitter font 83 |
84 |
85 |

86 | {t('settings.title')()} 87 |

88 |
89 | 90 |
91 | ) 92 | }, settingsContainer) 93 | disposeSettings = () => { 94 | disposeSettingsApp() 95 | settingsContainer.remove() 96 | } 97 | onCleanup(disposeSettings) 98 | } 99 | 100 | createMutationObserver( 101 | document.body, 102 | { childList: true, subtree: true }, 103 | throttle(() => { 104 | // since this throttled fn can be called after page navigation, 105 | // we need to check if the page is still the settings page. 106 | if (!location.pathname.startsWith('/settings')) 107 | return 108 | 109 | // workaround for profile editing popup 110 | if (location.pathname === '/settings/profile') 111 | return 112 | 113 | checkTab() 114 | 115 | if (location.pathname.match(`/settings/__imgtrans_${EDITION}`)) { 116 | if (settingsTab && settingsTab.children.length < 2) { 117 | settingsTab.style.backgroundColor = '#F7F9F9' 118 | const activeIndicator = document.createElement('div') 119 | activeIndicator.className = tw('absolute z-10 inset-0 border-y-0 border-l-0 border-r-2 border-solid border-[#1D9Bf0] pointer-events-none') 120 | settingsTab.appendChild(activeIndicator) 121 | } 122 | checkSettings() 123 | } 124 | else { 125 | if (settingsTab && settingsTab.children.length > 1) { 126 | settingsTab.style.backgroundColor = '' 127 | settingsTab.removeChild(settingsTab.lastChild!) 128 | } 129 | if (disposeSettings) { 130 | disposeSettings() 131 | disposeSettings = undefined 132 | } 133 | } 134 | }, 200), 135 | ) 136 | 137 | return { 138 | canKeep(url) { 139 | return url.includes('twitter.com') && url.includes('/settings') 140 | }, 141 | } 142 | } 143 | 144 | const settingsInjector: SettingsInjector = { 145 | match(url) { 146 | // https://twitter.com/settings/ 147 | return url.hostname.endsWith('twitter.com') 148 | && (url.pathname === '/settings' || url.pathname.match(/^\/settings\//)) 149 | && url.pathname !== '/settings/profile' 150 | }, 151 | mount, 152 | } 153 | 154 | export default settingsInjector 155 | -------------------------------------------------------------------------------- /userscript/src/utils/core.ts: -------------------------------------------------------------------------------- 1 | import type { Accessor } from 'solid-js' 2 | import { BCP47ToISO639, realLang, t } from '../i18n' 3 | import { 4 | detectionResolution, 5 | renderTextOrientation, 6 | targetLang, 7 | textDetector, 8 | translatorService, 9 | } from './storage' 10 | import { formatProgress } from '.' 11 | 12 | export async function resizeToSubmit(blob: Blob, suffix: string): Promise<{ blob: Blob; suffix: string }> { 13 | const blobUrl = URL.createObjectURL(blob) 14 | const img = await new Promise((resolve, reject) => { 15 | const img = new Image() 16 | img.onload = () => resolve(img) 17 | img.onerror = err => reject(err) 18 | img.src = blobUrl 19 | }) 20 | URL.revokeObjectURL(blobUrl) 21 | 22 | const w = img.width 23 | const h = img.height 24 | 25 | if (w <= 4096 && h <= 4096) 26 | return { blob, suffix } 27 | 28 | // resize to less than 4k 29 | const scale = Math.min(4096 / w, 4096 / h) 30 | const width = Math.floor(w * scale) 31 | const height = Math.floor(h * scale) 32 | 33 | const canvas = document.createElement('canvas') 34 | canvas.width = width 35 | canvas.height = height 36 | 37 | const ctx = canvas.getContext('2d')! 38 | ctx.imageSmoothingQuality = 'high' 39 | ctx.drawImage(img, 0, 0, width, height) 40 | 41 | const newBlob = await new Promise((resolve, reject) => { 42 | canvas.toBlob((blob) => { 43 | if (blob) 44 | resolve(blob) 45 | else 46 | reject(new Error('Canvas toBlob failed')) 47 | }, 'image/png') 48 | }) 49 | 50 | // console.log(`resized from ${w}x${h}(${formatSize(blob.size)},${suffix}) to ${width}x${height}(${formatSize(newBlob.size)},png)`) 51 | 52 | return { 53 | blob: newBlob, 54 | suffix: 'png', 55 | } 56 | } 57 | 58 | export interface TaskResult { 59 | translation_mask: string 60 | } 61 | 62 | export interface TranslateOptionsOverwrite { 63 | detectionResolution?: string 64 | renderTextOrientation?: string 65 | textDetector?: string 66 | translator?: string 67 | forceRetry?: boolean 68 | } 69 | export async function submitTranslate( 70 | blob: Blob, 71 | suffix: string, 72 | listeners: { 73 | onProgress?: (progress: string) => void 74 | onFinal?: () => void 75 | } = {}, 76 | optionsOverwrite?: TranslateOptionsOverwrite, 77 | ): Promise<{ 78 | id: string 79 | status: string 80 | result?: TaskResult 81 | }> { 82 | const { onProgress, onFinal } = listeners 83 | 84 | const formData = new FormData() 85 | formData.append('file', blob, `image.${suffix}`) 86 | formData.append('target_language', targetLang() || BCP47ToISO639(realLang())) 87 | formData.append('detector', optionsOverwrite?.textDetector ?? textDetector()) 88 | formData.append('direction', optionsOverwrite?.renderTextOrientation ?? renderTextOrientation()) 89 | formData.append('translator', optionsOverwrite?.translator ?? translatorService()) 90 | formData.append('size', optionsOverwrite?.detectionResolution ?? detectionResolution()) 91 | formData.append('retry', optionsOverwrite?.forceRetry ? 'true' : 'false') 92 | 93 | const result = await GMP.xmlHttpRequest({ 94 | method: 'POST', 95 | url: 'https://api.cotrans.touhou.ai/task/upload/v1', 96 | // @ts-expect-error FormData is supported 97 | data: formData, 98 | upload: { 99 | onprogress: onProgress || onFinal 100 | ? (e: ProgressEvent) => { 101 | if (e.lengthComputable) { 102 | if (e.loaded >= e.total - 16) { 103 | onFinal?.() 104 | } 105 | else { 106 | const p = formatProgress(e.loaded, e.total) 107 | onProgress?.(p) 108 | } 109 | } 110 | } 111 | : undefined, 112 | }, 113 | }) 114 | 115 | // console.log(result.responseText) 116 | return JSON.parse(result.responseText) 117 | } 118 | 119 | export function getStatusText(msg: QueryV1Message): Accessor { 120 | if (msg.type === 'pending') 121 | return t('common.status.pending-pos', { pos: msg.pos }) 122 | if (msg.type === 'status') 123 | return t(`common.status.${msg.status}`) 124 | return t('common.status.default') 125 | } 126 | 127 | type QueryV1Message = { 128 | type: 'pending' 129 | pos: number 130 | } | { 131 | type: 'status' 132 | status: string 133 | } | { 134 | type: 'result' 135 | result: { 136 | translation_mask: string 137 | } 138 | } | { 139 | type: 'error' 140 | error_id?: string 141 | } | { 142 | type: 'not_found' 143 | } 144 | 145 | export function pullTranslationStatus(id: string, cb: (status: Accessor) => void) { 146 | const ws = new WebSocket(`wss://api.cotrans.touhou.ai/task/${id}/event/v1`) 147 | 148 | return new Promise((resolve, reject) => { 149 | ws.onmessage = (e) => { 150 | const msg = JSON.parse(e.data) as QueryV1Message 151 | if (msg.type === 'result') 152 | resolve(msg.result) 153 | else if (msg.type === 'error') 154 | reject(t('common.status.error-with-id', { id: msg.error_id })) 155 | else 156 | cb(getStatusText(msg)) 157 | } 158 | }) 159 | } 160 | 161 | export async function pullTranslationStatusPolling(id: string, cb: (status: Accessor) => void) { 162 | while (true) { 163 | const res = await GMP.xmlHttpRequest({ 164 | method: 'GET', 165 | url: `https://api.cotrans.touhou.ai/task/${id}/status/v1`, 166 | }) 167 | const msg = JSON.parse(res.responseText) as QueryV1Message 168 | if (msg.type === 'result') 169 | return msg.result 170 | else if (msg.type === 'error') 171 | throw t('common.status.error-with-id', { id: msg.error_id }) 172 | else 173 | cb(getStatusText(msg)) 174 | 175 | await new Promise(resolve => setTimeout(resolve, 1000)) 176 | } 177 | } 178 | 179 | export async function downloadBlob( 180 | url: string, 181 | listeners: { 182 | onProgress?: (progress: string) => void 183 | } = {}, 184 | ): Promise { 185 | const { onProgress } = listeners 186 | 187 | const res = await GMP.xmlHttpRequest({ 188 | method: 'GET', 189 | responseType: 'blob', 190 | url, 191 | onprogress: onProgress 192 | ? (e) => { 193 | if (e.lengthComputable) { 194 | const p = formatProgress(e.loaded, e.total) 195 | onProgress(p) 196 | } 197 | } 198 | : undefined, 199 | }) 200 | 201 | return res.response as Blob 202 | } 203 | 204 | export async function blobToImageData(blob: Blob): Promise { 205 | const blobUrl = URL.createObjectURL(blob) 206 | 207 | const img = await new Promise((resolve, reject) => { 208 | const img = new Image() 209 | img.onload = () => resolve(img) 210 | img.onerror = err => reject(err) 211 | img.src = blobUrl 212 | }) 213 | URL.revokeObjectURL(blobUrl) 214 | 215 | const w = img.width 216 | const h = img.height 217 | 218 | const canvas = document.createElement('canvas') 219 | canvas.width = w 220 | canvas.height = h 221 | 222 | const ctx = canvas.getContext('2d')! 223 | ctx.drawImage(img, 0, 0) 224 | return ctx.getImageData(0, 0, w, h) 225 | } 226 | 227 | export async function imageDataToBlob(imageData: ImageData): Promise { 228 | const canvas = document.createElement('canvas') 229 | canvas.width = imageData.width 230 | canvas.height = imageData.height 231 | const ctx = canvas.getContext('2d')! 232 | ctx.putImageData(imageData, 0, 0) 233 | 234 | const blob = await new Promise((resolve, reject) => { 235 | canvas.toBlob((blob) => { 236 | if (blob) 237 | resolve(blob) 238 | else 239 | reject(new Error('Canvas toBlob failed')) 240 | }, 'image/png') 241 | }) 242 | 243 | return blob 244 | } 245 | -------------------------------------------------------------------------------- /userscript/src/utils/index.ts: -------------------------------------------------------------------------------- 1 | const mimeMap: Record = { 2 | png: 'image/png', 3 | jpg: 'image/jpeg', 4 | gif: 'image/gif', 5 | webp: 'image/webp', 6 | } 7 | export function suffixToMime(suffix: string) { 8 | return mimeMap[suffix] 9 | } 10 | 11 | export function formatSize(bytes: number) { 12 | const k = 1024 13 | const sizes = ['B', 'KB', 'MB', 'GB', 'TB'] 14 | if (bytes === 0) 15 | return '0B' 16 | const i = Math.floor(Math.log(bytes) / Math.log(k)) 17 | return `${(bytes / k ** i).toFixed(2)}${sizes[i]}` 18 | } 19 | export function formatProgress(loaded: number, total: number) { 20 | return `${formatSize(loaded)}/${formatSize(total)}` 21 | } 22 | 23 | export function assert(condition: unknown, message?: string): asserts condition { 24 | if (!condition) 25 | throw new Error(message) 26 | } 27 | -------------------------------------------------------------------------------- /userscript/src/utils/storage.ts: -------------------------------------------------------------------------------- 1 | import type { Accessor, Setter } from 'solid-js' 2 | import { createEffect, createSignal, on, onCleanup } from 'solid-js' 3 | import type { 4 | DetectResOption, 5 | KeepInstancesOption, 6 | RenderTextDirOption, 7 | ScriptLangOption, 8 | TargetLangOption, 9 | TextDetectorOption, 10 | TranslatorOption, 11 | } from '../settings' 12 | 13 | export type GMSignal = [ 14 | Accessor & { 15 | ready: Promise 16 | isReady: Accessor 17 | }, 18 | Setter, 19 | ] 20 | 21 | export function createGMSignal(key: string): GMSignal 22 | export function createGMSignal(key: string, initialValue: T): GMSignal 23 | export function createGMSignal(key: string, initialValue?: T) { 24 | const [signal, setSignal] = createSignal(initialValue) as GMSignal 25 | 26 | let listener: number | undefined 27 | 28 | Promise.resolve() 29 | .then(() => GMP.addValueChangeListener?.(key, (name, oldValue, newValue, remote) => { 30 | if (name === key && (remote === undefined || remote === true)) 31 | read(newValue) 32 | })) 33 | .then(l => listener = l) 34 | 35 | let effectPaused = false 36 | createEffect(on(signal, () => { 37 | if (effectPaused) 38 | return 39 | if (signal() == null) { 40 | GMP.deleteValue(key) 41 | effectPaused = true 42 | setSignal(() => initialValue) 43 | effectPaused = false 44 | } 45 | else { 46 | GMP.setValue(key, signal()) 47 | } 48 | }, { defer: true })) 49 | 50 | async function read(newValue?: string) { 51 | effectPaused = true 52 | 53 | const rawValue = newValue ?? (await GMP.getValue(key)) 54 | if (rawValue == null) 55 | setSignal(() => initialValue) 56 | else 57 | setSignal(() => rawValue as T) 58 | 59 | effectPaused = false 60 | } 61 | 62 | const [isReady, setIsReady] = createSignal(false) 63 | signal.isReady = isReady 64 | signal.ready = read() 65 | .then(() => { 66 | setIsReady(true) 67 | }) 68 | 69 | onCleanup(() => { 70 | if (listener) 71 | GMP.removeValueChangeListener?.(listener) 72 | }) 73 | 74 | return [signal, setSignal] 75 | } 76 | 77 | export const [detectionResolution, setDetectionResolution] = createGMSignal('detectionResolution', 'M') 78 | export const [textDetector, setTextDetector] = createGMSignal('textDetector', 'default') 79 | export const [translatorService, setTranslatorService] = createGMSignal('translator', 'gpt3.5') 80 | export const [renderTextOrientation, setRenderTextOrientation] = createGMSignal('renderTextOrientation', 'auto') 81 | export const [targetLang, setTargetLang] = createGMSignal('targetLang', '') 82 | export const [scriptLang, setScriptLang] = createGMSignal('scriptLanguage', '') 83 | export const [keepInstances, setKeepInstances] = createGMSignal('keepInstances', 'until-reload') 84 | 85 | export const storageReady = Promise.all([ 86 | detectionResolution.ready, 87 | textDetector.ready, 88 | translatorService.ready, 89 | renderTextOrientation.ready, 90 | targetLang.ready, 91 | scriptLang.ready, 92 | keepInstances.ready, 93 | ]) 94 | -------------------------------------------------------------------------------- /userscript/src/utils/twind.ts: -------------------------------------------------------------------------------- 1 | import { dom, twind } from '@twind/core' 2 | import presetAutoprefix from '@twind/preset-autoprefix' 3 | import presetTailwind from '@twind/preset-tailwind' 4 | 5 | export const tw = twind({ 6 | preflight: false, 7 | hash: (className, defaultHash) => { 8 | return `tw-${defaultHash(className).slice(1)}` 9 | }, 10 | presets: [ 11 | presetAutoprefix(), 12 | presetTailwind({ 13 | disablePreflight: true, 14 | }), 15 | ], 16 | }, dom()) 17 | -------------------------------------------------------------------------------- /userscript/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "module": "ESNext", 6 | "lib": ["ESNext", "DOM"], 7 | "types": ["tampermonkey", "vitest", "unplugin-icons/types/solid"], 8 | "moduleResolution": "Node", 9 | "jsx": "preserve", 10 | "jsxImportSource": "solid-js", 11 | "strict": true, 12 | "resolveJsonModule": true, 13 | "esModuleInterop": true, 14 | "noEmit": true, 15 | "noImplicitReturns": true 16 | }, 17 | "include": ["./src"] 18 | } 19 | -------------------------------------------------------------------------------- /userscript/vitest.config.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | test: { 3 | environment: 'happy-dom', 4 | globals: true, 5 | // segment fault, idk 6 | threads: false, 7 | }, 8 | } 9 | -------------------------------------------------------------------------------- /web/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.log* 3 | .nuxt 4 | .nitro 5 | .cache 6 | .output 7 | .env 8 | dist 9 | -------------------------------------------------------------------------------- /web/app.vue: -------------------------------------------------------------------------------- 1 | 7 | -------------------------------------------------------------------------------- /web/components/footer/index.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 89 | -------------------------------------------------------------------------------- /web/components/nav/index.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 33 | -------------------------------------------------------------------------------- /web/components/u/Listbox.vue: -------------------------------------------------------------------------------- 1 | 32 | 33 | 103 | -------------------------------------------------------------------------------- /web/layouts/default.vue: -------------------------------------------------------------------------------- 1 | 3 | 4 | 13 | 14 | 22 | -------------------------------------------------------------------------------- /web/nuxt.config.ts: -------------------------------------------------------------------------------- 1 | // https://v3.nuxtjs.org/api/configuration/nuxt.config 2 | export default defineNuxtConfig({ 3 | ssr: false, 4 | 5 | modules: [ 6 | '@vueuse/nuxt', 7 | '@unocss/nuxt', 8 | 'nuxt-headlessui', 9 | ], 10 | 11 | typescript: { 12 | strict: true, 13 | }, 14 | 15 | runtimeConfig: { 16 | public: { 17 | apiBase: 'https://api.cotrans.touhou.ai', 18 | wsBase: 'wss://api.cotrans.touhou.ai', 19 | }, 20 | }, 21 | 22 | unocss: { 23 | preflight: true, 24 | uno: true, 25 | icons: { 26 | scale: 1.2, 27 | extraProperties: { 28 | 'color': 'inherit', 29 | // Avoid crushing of icons in crowded situations 30 | 'min-width': '1.2em', 31 | }, 32 | }, 33 | webFonts: { 34 | provider: 'google', 35 | fonts: { 36 | quicksand: 'Quicksand:300,400,500,600,700', 37 | }, 38 | }, 39 | shortcuts: [ 40 | [ 41 | 'nav-link', 42 | [ 43 | 'absolute', 44 | 'content-empty', 45 | 'left-0', 46 | 'bottom-0', 47 | 'w-full', 48 | 'h-px', 49 | 'opacity-0', 50 | 'bg-gradient-to-r', 51 | 'from-fuchsia-600', 52 | 'to-pink-600', 53 | 'transform', 54 | '-translate-y-1', 55 | 'transition-all', 56 | 'ease-out', 57 | 'hover:opacity-60', 58 | 'hover:translate-y-0', 59 | ] 60 | .map(c => `after:${c}`) 61 | .concat([ 62 | 'relative', 63 | ]) 64 | .join(' '), 65 | ], 66 | ], 67 | }, 68 | 69 | headlessui: { 70 | prefix: 'H', 71 | }, 72 | }) 73 | -------------------------------------------------------------------------------- /web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "scripts": { 4 | "build": "nuxt build", 5 | "dev": "nuxt dev", 6 | "generate": "nuxt generate", 7 | "preview": "nuxt preview", 8 | "postinstall": "nuxt prepare" 9 | }, 10 | "devDependencies": { 11 | "@iconify-json/ri": "^1.1.10", 12 | "@unocss/nuxt": "^0.53.6", 13 | "@vueuse/core": "^10.2.1", 14 | "@vueuse/nuxt": "^10.2.1", 15 | "nuxt": "^3.6.5", 16 | "nuxt-headlessui": "^1.1.4" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /web/pages/userscript.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | -------------------------------------------------------------------------------- /web/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // https://v3.nuxtjs.org/concepts/typescript 3 | "extends": "./.nuxt/tsconfig.json" 4 | } 5 | -------------------------------------------------------------------------------- /wk-gateway/.gitignore: -------------------------------------------------------------------------------- 1 | /dist/ 2 | -------------------------------------------------------------------------------- /wk-gateway/buf.gen.yaml: -------------------------------------------------------------------------------- 1 | # Learn more: https://docs.buf.build/configuration/v1/buf-gen-yaml 2 | version: v1 3 | plugins: 4 | - plugin: es 5 | opt: target=ts 6 | out: src/protoGen 7 | -------------------------------------------------------------------------------- /wk-gateway/build.config.ts: -------------------------------------------------------------------------------- 1 | import { defineBuildConfig } from 'unbuild' 2 | import { version } from './package.json' 3 | import { dbEnum } from './src/db' 4 | 5 | function flattenObject( 6 | obj: Record, 7 | parentKey = '', 8 | result: Record = {}, 9 | ) { 10 | for (const key in obj) { 11 | if (Object.hasOwn(obj, key)) { 12 | const propName = parentKey ? `${parentKey}.${key}` : key 13 | if ( 14 | (typeof obj[key] === 'object' && obj[key] !== null) 15 | || typeof obj[key] === 'function' 16 | ) 17 | flattenObject(obj[key], propName, result) 18 | else 19 | result[propName] = JSON.stringify(obj[key]) 20 | } 21 | } 22 | return result 23 | } 24 | 25 | export default defineBuildConfig({ 26 | entries: [ 27 | { input: 'src/index.ts', name: 'index' }, 28 | { input: 'src/mitWorker/dObject.ts', name: 'doMitWorker' }, 29 | ], 30 | declaration: true, 31 | replace: { 32 | 'import.meta.env.VERSION': JSON.stringify(version), 33 | 'import.meta.env.BUILD_TIME': JSON.stringify(new Date().toISOString()), 34 | ...flattenObject(dbEnum, 'dbEnum'), 35 | }, 36 | rollup: { 37 | inlineDependencies: true, 38 | resolve: { 39 | exportConditions: ['browser'], 40 | }, 41 | }, 42 | hooks: { 43 | 'rollup:options': (ctx, opt) => { 44 | const external = opt.external 45 | opt.external = [] 46 | ctx.hooks.hook('rollup:dts:options', (ctx, opt) => { 47 | opt.external = external 48 | }) 49 | }, 50 | }, 51 | }) 52 | -------------------------------------------------------------------------------- /wk-gateway/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.1.0", 3 | "private": true, 4 | "main": "dist/index.mjs", 5 | "types": "dist/index.d.ts", 6 | "scripts": { 7 | "build": "unbuild", 8 | "postinstall": "rimraf src/protoGen && buf generate ../proto" 9 | }, 10 | "dependencies": { 11 | "@bufbuild/protobuf": "^1.3.0", 12 | "@paralleldrive/cuid2": "^2.2.1", 13 | "hono": "^3.3.2", 14 | "jose": "^4.14.4", 15 | "ofetch": "^1.1.1", 16 | "zod": "^3.21.4" 17 | }, 18 | "devDependencies": { 19 | "@bufbuild/buf": "^1.25.0", 20 | "@bufbuild/protoc-gen-es": "^1.3.0", 21 | "@cloudflare/workers-types": "^4.20230717.1", 22 | "@cotrans/types": "workspace:^", 23 | "@types/node": "^20.4.4", 24 | "rimraf": "^5.0.1", 25 | "unbuild": "^1.2.1" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /wk-gateway/shims.d.ts: -------------------------------------------------------------------------------- 1 | interface ImportMeta { 2 | env: ImportMetaEnv 3 | } 4 | 5 | interface ImportMetaEnv { 6 | VERSION: string 7 | BUILD_TIME: string 8 | } 9 | -------------------------------------------------------------------------------- /wk-gateway/src/db.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @example 3 | * const en = createEnum(['!removed', '~deprecated', 'normal']) 4 | * en.removed // never 5 | * en.deprecated // never 6 | * en.normal // 3 7 | * en(number) // 'normal' | 'deprecated' 8 | * type Union = keyof typeof en // 'normal' 9 | * type UnionDeprecated = ReturnType // 'normal' | 'deprecated' 10 | */ 11 | function createEnum< 12 | const T extends readonly string[], 13 | K extends T[number], 14 | U extends Range, 15 | >(values: T): 16 | & { [I in U as Exclude]: PlusOne } 17 | & ((i: number) => K extends `~${infer S}` ? S : Exclude) { 18 | const reverse = values.map(v => v[0] === '!' ? undefined : v[0] === '~' ? v.slice(1) : v) 19 | return Object.assign( 20 | (i: number) => reverse[i - 1], 21 | Object.fromEntries( 22 | values 23 | .filter(v => v[0] !== '~' && v[0] !== '!') 24 | .map((v, i) => [v, i + 1]), 25 | ), 26 | ) as never 27 | } 28 | type Range = 29 | C['length'] extends N ? I : Range 30 | type PlusOne = 31 | B['length'] extends N ? C['length'] : PlusOne 32 | 33 | export const dbEnum = { 34 | taskState: createEnum(['pending', 'running', 'done', 'error']), 35 | taskLanguage: createEnum(['CHS', 'CHT', 'CSY', 'NLD', 'ENG', 'FRA', 'DEU', 'HUN', 'ITA', 'JPN', 'KOR', 'PLK', 'PTB', 'ROM', 'RUS', 'ESP', 'TRK', 'UKR', 'VIN']), 36 | taskDetector: createEnum(['default', 'ctd']), 37 | taskDirection: createEnum(['auto', 'h', 'v']), 38 | taskTranslator: createEnum(['gpt3.5', 'youdao', 'baidu', 'google', 'deepl', 'papago', 'offline', 'none', 'original']), 39 | taskSize: createEnum(['S', 'M', 'L', 'X']), 40 | } 41 | -------------------------------------------------------------------------------- /wk-gateway/src/index.ts: -------------------------------------------------------------------------------- 1 | import { Hono } from 'hono' 2 | import { cors } from 'hono/cors' 3 | import { HTTPException } from 'hono/http-exception' 4 | import { ofetch } from 'ofetch' 5 | import type { Bindings } from './types' 6 | import { taskApp } from './task' 7 | import { mitWorkerApp } from './mitWorker' 8 | 9 | const CORS_ORIGINS: (string | RegExp)[] = [ 10 | /https?:\/\/localhost(?::\d+)?/, 11 | 'https://cotrans.touhou.ai', 12 | ] 13 | 14 | const app = new Hono<{ Bindings: Bindings }>() 15 | .use('*', cors({ 16 | origin: origin => 17 | CORS_ORIGINS.some( 18 | o => typeof o === 'string' 19 | ? o === origin 20 | : o.test(origin), 21 | ) 22 | ? origin 23 | : null, 24 | allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], 25 | maxAge: 10 * 60, 26 | })) 27 | .get('/', ({ text }) => text(`Cotrans API by VoileLabs ${import.meta.env.VERSION}`)) 28 | .get('/status/v1', async ({ env, json }) => { 29 | const mitWorkerId = env.doMitWorker.idFromName('default') 30 | const mitWorker = await ofetch('https://fake-host/status', { 31 | fetcher: env.doMitWorker.get(mitWorkerId, { locationHint: 'enam' }), 32 | }) 33 | return json({ 34 | version: import.meta.env.VERSION, 35 | build_time: import.meta.env.BUILD_TIME, 36 | mit_worker: mitWorker, 37 | }) 38 | }) 39 | .route('/task', taskApp) 40 | .route('/mit', mitWorkerApp) 41 | .onError((err) => { 42 | if (err instanceof HTTPException) { 43 | // get the custom response 44 | return err.getResponse() 45 | } 46 | 47 | console.error(String(err instanceof Error ? (err.stack ?? err) : err)) 48 | 49 | // return a generic response 50 | return new Response('Internal Server Error', { status: 500 }) 51 | }) 52 | 53 | export default app 54 | -------------------------------------------------------------------------------- /wk-gateway/src/mitWorker/id.ts: -------------------------------------------------------------------------------- 1 | // k-sortable time based id generator 2 | // with some help from ChatGPT, obviously 3 | 4 | import { memo } from '../utils' 5 | 6 | const characters = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz' 7 | 8 | function convertToBase62(num: number) { 9 | let result = '' 10 | do { 11 | result = characters[num % 62] + result 12 | num = Math.floor(num / 62) 13 | } while (num > 0) 14 | return result 15 | } 16 | 17 | let sequenceNumber = 0 18 | 19 | const machineId = memo(() => { 20 | // Generating a unique random machine identifier using crypto.getRandomValues 21 | const array = new Uint8Array(3) 22 | crypto.getRandomValues(array) 23 | return Array.from(array).map(b => (`0${b.toString(16)}`).slice(-2)).join('') 24 | }) 25 | 26 | export function createSortableId() { 27 | const timestamp = new Date().getTime() 28 | 29 | // Increment sequence number and reset if it's too large 30 | sequenceNumber = (sequenceNumber + 1) & 0xFFFFFF 31 | 32 | const base62Timestamp = convertToBase62(timestamp) 33 | const base62Sequence = convertToBase62(sequenceNumber).padStart(2, '0') 34 | 35 | // Combine timestamp, machineId, and sequenceNumber 36 | return base62Timestamp + machineId() + base62Sequence 37 | } 38 | -------------------------------------------------------------------------------- /wk-gateway/src/mitWorker/index.ts: -------------------------------------------------------------------------------- 1 | import { Hono } from 'hono' 2 | import type { Bindings } from '../types' 3 | 4 | export const mitWorkerApp = new Hono<{ Bindings: Bindings }>() 5 | .get('/worker_ws', async ({ env, req }) => { 6 | if (req.header('Upgrade') !== 'websocket') 7 | return new Response('Not a websocket request', { status: 400 }) 8 | 9 | const encoder = new TextEncoder() 10 | if (!req.header('x-secret')) 11 | return new Response('Forbidden', { status: 403 }) 12 | // @ts-expect-error Cloudflare only 13 | if (!crypto.subtle.timingSafeEqual( 14 | encoder.encode(req.header('x-secret')!), 15 | encoder.encode(env.MIT_WORKERS_SECRET), 16 | )) 17 | return new Response('Forbidden', { status: 403 }) 18 | 19 | // pass the request to durable object 20 | const id = env.doMitWorker.idFromName('default') 21 | return env.doMitWorker.get(id, { locationHint: 'enam' }) 22 | .fetch('https://fake-host/worker_ws', req.raw) 23 | .then(res => new Response(res.body, res)) 24 | }) 25 | -------------------------------------------------------------------------------- /wk-gateway/src/mitWorker/ttl.ts: -------------------------------------------------------------------------------- 1 | export class TTLSet implements Set { 2 | private map: Map = new Map() 3 | private generatorPair: () => IterableIterator<[T, T]> 4 | private generator: () => IterableIterator 5 | private lastCleanedAt = Date.now() 6 | 7 | constructor(public ttl: number) { 8 | const map = this.map 9 | this.generatorPair = function* (): IterableIterator<[T, T]> { 10 | const l = Date.now() - ttl 11 | for (const [key, t] of map.entries()) { 12 | if (t > l) 13 | yield [key, key] 14 | } 15 | } 16 | this.generator = function* (): IterableIterator { 17 | const l = Date.now() - ttl 18 | for (const [key, t] of map.entries()) { 19 | if (t > l) 20 | yield key 21 | } 22 | } 23 | } 24 | 25 | add(value: T): this { 26 | const t = Date.now() 27 | 28 | if (t - this.lastCleanedAt > this.ttl) { 29 | this.lastCleanedAt = t 30 | for (const [key, t] of this.map.entries()) { 31 | if (t + this.ttl < t) 32 | this.map.delete(key) 33 | } 34 | } 35 | 36 | this.map.set(value, t) 37 | return this 38 | } 39 | 40 | clear(): void { 41 | this.map.clear() 42 | } 43 | 44 | delete(value: T): boolean { 45 | return this.map.delete(value) 46 | } 47 | 48 | forEach(callbackfn: (value: T, value2: T, set: Set) => void, thisArg?: any): void { 49 | this.map.forEach((_, key) => callbackfn(key, key, this), thisArg) 50 | } 51 | 52 | has(value: T): boolean { 53 | return (this.map.get(value) ?? 0) + this.ttl > Date.now() 54 | } 55 | 56 | get size(): number { 57 | return this.map.size 58 | } 59 | 60 | [Symbol.iterator](): IterableIterator { 61 | return this.generator() 62 | } 63 | 64 | entries(): IterableIterator<[T, T]> { 65 | return this.generatorPair() 66 | } 67 | 68 | keys(): IterableIterator { 69 | return this.generator() 70 | } 71 | 72 | values(): IterableIterator { 73 | return this.generator() 74 | } 75 | 76 | [Symbol.toStringTag] = 'TTLSet' 77 | } 78 | -------------------------------------------------------------------------------- /wk-gateway/src/task/index.ts: -------------------------------------------------------------------------------- 1 | import { Hono } from 'hono' 2 | import { ofetch } from 'ofetch' 3 | import type { QueryV1Message, QueryV1MessageNotFound } from '@cotrans/types' 4 | import type { Bindings } from '../types' 5 | import { dbEnum } from '../db' 6 | import { BLANK_PNG } from '../utils' 7 | import { upload } from './upload' 8 | 9 | export const taskApp = new Hono<{ Bindings: Bindings }>() 10 | .post('/upload/v1', upload) 11 | .put('/upload/v1', upload) 12 | .get('/group/:group/event/v1', async ({ env, req }) => { 13 | const group = req.param('group') 14 | 15 | if (req.header('Upgrade') !== 'websocket') 16 | throw new Error('Not a websocket request') 17 | 18 | const mitWorkerId = env.doMitWorker.idFromName('default') 19 | return await env.doMitWorker 20 | .get(mitWorkerId, { locationHint: 'enam' }) 21 | .fetch(`https://fake-host/group/event/${group}`, req.raw) 22 | .then(res => new Response(res.body, res)) 23 | }) 24 | .get('/:id/status/v1', async ({ env, req, json }) => { 25 | const id = req.param('id') 26 | 27 | const mitWorkerId = env.doMitWorker.idFromName('default') 28 | const mitWorkerResult = await ofetch(`https://fake-host/status/${id}`, { 29 | fetcher: env.doMitWorker.get(mitWorkerId, { locationHint: 'enam' }), 30 | }) 31 | 32 | if (mitWorkerResult.type !== 'not_found') 33 | return json(mitWorkerResult) 34 | 35 | const dbResult = await env.DB 36 | .prepare('SELECT state, translation_mask FROM task WHERE id = ?') 37 | .bind(id) 38 | .first<{ state: number; translation_mask: string } | null>() 39 | 40 | if (!dbResult) 41 | return json({ type: 'not_found' } satisfies QueryV1Message) 42 | 43 | if (dbResult.state === dbEnum.taskState.done) { 44 | return json({ 45 | type: 'result', 46 | result: { 47 | translation_mask: dbResult.translation_mask 48 | ? `${env.WKR2_PUBLIC_EXPOSED_BASE}/${dbResult.translation_mask}` 49 | : BLANK_PNG, 50 | }, 51 | } satisfies QueryV1Message) 52 | } 53 | else if (dbResult.state === dbEnum.taskState.error) { 54 | return json({ type: 'error' } satisfies QueryV1Message) 55 | } 56 | else { 57 | return json({ 58 | type: 'status', 59 | status: 'pending', 60 | } satisfies QueryV1Message) 61 | } 62 | }) 63 | .get('/:id/event/v1', async ({ env, req }) => { 64 | const id = req.param('id') 65 | 66 | if (req.header('Upgrade') !== 'websocket') 67 | throw new Error('Not a websocket request') 68 | 69 | const mitWorkerId = env.doMitWorker.idFromName('default') 70 | const mitWorkerResult = await env.doMitWorker 71 | .get(mitWorkerId, { locationHint: 'enam' }) 72 | .fetch(`https://fake-host/event/${id}`, req.raw) 73 | 74 | if (mitWorkerResult.status === 101) 75 | return new Response(mitWorkerResult.body, mitWorkerResult) 76 | 77 | const status = await mitWorkerResult.json() 78 | if (status.type === 'not_found') { 79 | const dbResult = await env.DB 80 | .prepare('SELECT state, translation_mask FROM task WHERE id = ?') 81 | .bind(id) 82 | .first<{ state: number; translation_mask: string | null } | null>() 83 | 84 | const sendAndClose = (data: QueryV1Message) => { 85 | const pair = new WebSocketPair() 86 | // @ts-expect-error Cloudflare only 87 | pair[1].accept() 88 | pair[1].send(JSON.stringify(data)) 89 | pair[1].close() 90 | return new Response(null, { status: 101, webSocket: pair[0] }) 91 | } 92 | 93 | if (!dbResult) 94 | return sendAndClose({ type: 'not_found' }) 95 | 96 | if (dbResult.state === dbEnum.taskState.done) { 97 | return sendAndClose({ 98 | type: 'result', 99 | result: { 100 | translation_mask: dbResult.translation_mask 101 | ? `${env.WKR2_PUBLIC_EXPOSED_BASE}/${dbResult.translation_mask}` 102 | : BLANK_PNG, 103 | }, 104 | }) 105 | } 106 | else if (dbResult.state === dbEnum.taskState.error) { 107 | return sendAndClose({ type: 'error' }) 108 | } 109 | else { 110 | return sendAndClose({ 111 | type: 'status', 112 | status: 'pending', 113 | }) 114 | } 115 | } 116 | else { 117 | throw new Error('Unexpected response') 118 | } 119 | }) 120 | -------------------------------------------------------------------------------- /wk-gateway/src/types.ts: -------------------------------------------------------------------------------- 1 | // FIXME Type '{ Bindings: Bindings; }' does not satisfy the constraint 'Env'. 2 | // Types of property 'Bindings' are incompatible. 3 | // eslint-disable-next-line @typescript-eslint/consistent-type-definitions 4 | export type Bindings = { 5 | // TODO use mTLS 6 | MIT_WORKERS_SECRET: string 7 | 8 | JWT_PRIVATE_KEY: string 9 | JWT_PUBLIC_KEY: string 10 | 11 | WKR2_PRIVATE_BASE: string 12 | WKR2_PUBLIC_BASE: string 13 | WKR2_PUBLIC_EXPOSED_BASE: string 14 | 15 | DB: D1Database 16 | doMitWorker: DurableObjectNamespace 17 | doImage: DurableObjectNamespace 18 | wkr2_private: Fetcher 19 | wkr2_public: Fetcher 20 | } 21 | -------------------------------------------------------------------------------- /wk-gateway/src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './memo' 2 | export * from './png' 3 | -------------------------------------------------------------------------------- /wk-gateway/src/utils/memo.ts: -------------------------------------------------------------------------------- 1 | export function memo(fn: () => NonNullable): () => NonNullable { 2 | let cache: T 3 | return () => cache ??= fn() 4 | } 5 | -------------------------------------------------------------------------------- /wk-gateway/src/utils/png.ts: -------------------------------------------------------------------------------- 1 | export const BLANK_PNG = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVQI12NgYAAAAAMAASDVlMcAAAAASUVORK5CYII=' 2 | -------------------------------------------------------------------------------- /wk-gateway/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "module": "ESNext", 5 | "target": "ESNext", 6 | "lib": ["ESNext", "DOM", "DOM.Iterable"], 7 | "types": ["@cloudflare/workers-types"], 8 | "strict": true, 9 | "esModuleInterop": true, 10 | "skipLibCheck": true, 11 | "moduleResolution": "node", 12 | "jsx": "preserve", 13 | "resolveJsonModule": true, 14 | "strictNullChecks": true, 15 | "forceConsistentCasingInFileNames": true, 16 | "paths": { 17 | "@/*": ["*"] 18 | } 19 | }, 20 | "include": ["**/*.ts"], 21 | "exclude": ["dist", "node_modules"] 22 | } 23 | -------------------------------------------------------------------------------- /wk-gateway/wrangler.domitworker.toml: -------------------------------------------------------------------------------- 1 | name = 'cotrans-wk-gateway-domitworker' 2 | main = 'dist/doMitWorker.mjs' 3 | compatibility_date = '2023-07-09' 4 | compatibility_flags = [] 5 | workers_dev = false 6 | usage_model = 'bundled' 7 | 8 | [[migrations]] 9 | tag = "v1" 10 | new_classes = ["DOMitWorker"] 11 | 12 | [durable_objects] 13 | bindings = [{ name = "doMitWorker", class_name = "DOMitWorker" }] 14 | 15 | [[d1_databases]] 16 | binding = "DB" 17 | database_name = "cotrans" 18 | database_id = "5586b253-f5be-44cc-9ae4-284029d78da0" 19 | migrations_dir = "../migrations" 20 | -------------------------------------------------------------------------------- /wk-gateway/wrangler.toml: -------------------------------------------------------------------------------- 1 | name = 'cotrans-wk-gateway' 2 | main = 'dist/index.mjs' 3 | compatibility_date = '2023-07-09' 4 | compatibility_flags = [] 5 | workers_dev = false 6 | usage_model = 'bundled' 7 | services = [ 8 | { binding = "wkr2_private", service = "cotrans-wkr2-private" }, 9 | { binding = "wkr2_public", service = "cotrans-wkr2-public" }, 10 | ] 11 | 12 | [durable_objects] 13 | bindings = [ 14 | { name = "doMitWorker", class_name = "DOMitWorker", script_name = "cotrans-wk-gateway-domitworker" }, 15 | { name = "doImage", class_name = "DOImage", script_name = "cotrans-wk-image" }, 16 | ] 17 | 18 | [[d1_databases]] 19 | binding = "DB" 20 | database_name = "cotrans" 21 | database_id = "5586b253-f5be-44cc-9ae4-284029d78da0" 22 | migrations_dir = "../migrations" 23 | 24 | [placement] 25 | mode = "smart" 26 | -------------------------------------------------------------------------------- /wk-image/.gitignore: -------------------------------------------------------------------------------- 1 | /build/ 2 | -------------------------------------------------------------------------------- /wk-image/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "cotrans-wk-image" 3 | version = "0.1.0" 4 | edition.workspace = true 5 | rust-version.workspace = true 6 | publish = false 7 | 8 | [lib] 9 | crate-type = ["cdylib"] 10 | 11 | [dependencies] 12 | once_cell = "1.18.0" 13 | hex = "0.4.3" 14 | serde = { version = "1.0.174", features = ["derive"] } 15 | serde_json = "1.0.103" 16 | console_error_panic_hook = "0.1.7" 17 | web-sys = { version = "=0.3.61", features = ["File", "Crypto", "SubtleCrypto"] } 18 | worker-sys = "0.0.9" 19 | worker = "0.0.17" 20 | 21 | image = "0.24.6" 22 | fast_image_resize = "2.7.3" 23 | image_hasher = { version = "1.2.0", path = "../img_hash", features = [ 24 | "nightly", 25 | ] } 26 | 27 | [package.metadata.wasm-pack.profile.release] 28 | wasm-opt = false 29 | -------------------------------------------------------------------------------- /wk-image/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true 3 | } 4 | -------------------------------------------------------------------------------- /wk-image/src/lib.rs: -------------------------------------------------------------------------------- 1 | use std::{io::Cursor, num::NonZeroU32}; 2 | 3 | use console_error_panic_hook::set_once as set_panic_hook; 4 | use fast_image_resize as fr; 5 | use image::{ 6 | codecs::png::{CompressionType, PngEncoder}, 7 | io::Reader as ImageReader, 8 | DynamicImage, ImageEncoder, ImageFormat, 9 | }; 10 | use image_hasher::{Hasher, HasherConfig}; 11 | use js_sys::{ArrayBuffer, Uint8Array}; 12 | use once_cell::sync::Lazy; 13 | use serde::Serialize; 14 | use wasm_bindgen::prelude::*; 15 | use wasm_bindgen_futures::JsFuture; 16 | use worker::*; 17 | use worker_sys::R2Bucket; 18 | 19 | static HASHER: Lazy = Lazy::new(|| { 20 | HasherConfig::new() 21 | .hash_size(10, 10) 22 | .resize_filter(image_hasher::FilterType::Lanczos3) 23 | .preproc_dct() 24 | .to_hasher() 25 | }); 26 | 27 | pub fn hash_image(image: &DynamicImage) -> String { 28 | let hash = HASHER.hash_image(image); 29 | hex::encode(hash.as_bytes()) 30 | } 31 | 32 | #[durable_object] 33 | pub struct DOImage { 34 | env: Env, 35 | } 36 | 37 | #[durable_object] 38 | impl DurableObject for DOImage { 39 | fn new(state: State, env: Env) -> Self { 40 | Self { env } 41 | } 42 | 43 | async fn fetch(&mut self, req: Request) -> Result { 44 | handle(req, &self.env).await 45 | } 46 | } 47 | 48 | #[derive(Serialize)] 49 | struct ResponseJson { 50 | key: String, 51 | width: u32, 52 | height: u32, 53 | size: usize, 54 | hash: String, 55 | sha: String, 56 | } 57 | 58 | async fn handle(mut req: Request, env: &Env) -> Result { 59 | set_panic_hook(); 60 | let worker: web_sys::WorkerGlobalScope = js_sys::global().unchecked_into(); 61 | let bucket_pri: R2Bucket = js_sys::Reflect::get(env, &JsValue::from("BUCKET_PRI")) 62 | .unwrap() 63 | .unchecked_into(); 64 | 65 | let form = req.form_data().await?; 66 | 67 | let Some(FormEntry::File(file)) = form.get("file") else { 68 | return Response::error("No file found", 400); 69 | }; 70 | let file = file.bytes().await?; 71 | 72 | let mime = match form.get("mime") { 73 | Some(FormEntry::Field(mime)) => Some(mime), 74 | _ => None, 75 | }; 76 | 77 | drop(form); 78 | 79 | let cursor = Cursor::new(file); 80 | let mut image = match mime { 81 | Some(mime) if !mime.is_empty() => { 82 | let Some(format) = ImageFormat::from_mime_type(mime) else { 83 | return Response::error("Invalid MIME type", 400); 84 | }; 85 | let Ok(image) = ImageReader::with_format(cursor, format).decode() else { 86 | return Response::error("Invalid image", 400); 87 | }; 88 | image 89 | } 90 | _ => { 91 | let Ok(reader) = ImageReader::new(cursor).with_guessed_format() else { 92 | return Response::error("Could not guess image format", 400); 93 | }; 94 | let Ok(image) = reader.decode() else { 95 | return Response::error("Invalid image", 400); 96 | }; 97 | image 98 | } 99 | }; 100 | 101 | let mut width = image.width(); 102 | let mut height = image.height(); 103 | 104 | image = match image { 105 | DynamicImage::ImageRgb8(_) => image, 106 | DynamicImage::ImageRgba8(_) => image, 107 | DynamicImage::ImageLuma8(_) => image, 108 | DynamicImage::ImageLumaA8(_) => image, 109 | _ => DynamicImage::ImageRgba8(image.to_rgba8()), 110 | }; 111 | 112 | // scale image to less than 6000x6000 113 | if width > 6000 || height > 6000 { 114 | let widthf: f64 = width as f64; 115 | let heightf: f64 = height as f64; 116 | 117 | let (nwidth, nheight) = if widthf > heightf { 118 | (6000, (6000. / widthf * heightf).round() as u32) 119 | } else { 120 | ((6000. / heightf * widthf).round() as u32, 6000) 121 | }; 122 | 123 | image = resize(image, nwidth, nheight); 124 | 125 | width = nwidth; 126 | height = nheight; 127 | } 128 | 129 | // sha using SubtleCrypto 130 | let sha: ArrayBuffer = JsFuture::from( 131 | worker 132 | .crypto()? 133 | .subtle() 134 | .digest_with_str_and_buffer_source( 135 | "SHA-256", 136 | &unsafe { Uint8Array::view(image.as_bytes()) }.into(), 137 | )?, 138 | ) 139 | .await? 140 | .unchecked_into(); 141 | let sha = hex::encode(Uint8Array::new(&sha).to_vec()); 142 | 143 | let mut png_buf: Vec = vec![]; 144 | if let Err(_) = PngEncoder::new_with_quality( 145 | &mut Cursor::new(&mut png_buf), 146 | CompressionType::Fast, 147 | image::codecs::png::FilterType::default(), 148 | ) 149 | .write_image(image.as_bytes(), width, height, image.color()) 150 | { 151 | return Response::error("Could not encode image", 500); 152 | }; 153 | 154 | let size = png_buf.len(); 155 | 156 | let key = "upload/".to_owned() + &sha + ".png"; 157 | let put_res = JsFuture::from(bucket_pri.put( 158 | key.clone(), 159 | unsafe { Uint8Array::view(&png_buf).into() }, 160 | JsValue::UNDEFINED, 161 | )); 162 | 163 | // hash the image while we wait for the upload to finish 164 | let hash = hash_image(&image); 165 | let _ = put_res.await?; 166 | 167 | Response::from_json(&ResponseJson { 168 | key, 169 | width, 170 | height, 171 | size, 172 | hash, 173 | sha, 174 | }) 175 | } 176 | 177 | fn resize(image: DynamicImage, width: u32, height: u32) -> DynamicImage { 178 | let pixel_type = match image { 179 | DynamicImage::ImageRgb8(_) => fr::PixelType::U8x3, 180 | DynamicImage::ImageRgba8(_) => fr::PixelType::U8x4, 181 | DynamicImage::ImageLuma8(_) => fr::PixelType::U8, 182 | DynamicImage::ImageLumaA8(_) => fr::PixelType::U8x2, 183 | _ => unreachable!(), 184 | }; 185 | 186 | let mut src_img = fr::Image::from_vec_u8( 187 | NonZeroU32::new(image.width()).unwrap(), 188 | NonZeroU32::new(image.height()).unwrap(), 189 | image.into_bytes(), 190 | pixel_type, 191 | ) 192 | .unwrap(); 193 | 194 | // multiple RGB channels of source image by alpha channel 195 | // (not required for the Nearest algorithm) 196 | let alpha_mul_div = fr::MulDiv::default(); 197 | if pixel_type == fr::PixelType::U8x4 || pixel_type == fr::PixelType::U8x2 { 198 | alpha_mul_div 199 | .multiply_alpha_inplace(&mut src_img.view_mut()) 200 | .unwrap(); 201 | } 202 | 203 | let mut dst_img = fr::Image::new( 204 | NonZeroU32::new(width).unwrap(), 205 | NonZeroU32::new(height).unwrap(), 206 | src_img.pixel_type(), 207 | ); 208 | 209 | let mut dst_view = dst_img.view_mut(); 210 | 211 | let mut resizer = fr::Resizer::new(fr::ResizeAlg::Convolution(fr::FilterType::Lanczos3)); 212 | resizer.resize(&src_img.view(), &mut dst_view).unwrap(); 213 | 214 | if pixel_type == fr::PixelType::U8x4 || pixel_type == fr::PixelType::U8x2 { 215 | alpha_mul_div.divide_alpha_inplace(&mut dst_view).unwrap(); 216 | } 217 | 218 | match pixel_type { 219 | fr::PixelType::U8x3 => DynamicImage::ImageRgb8( 220 | image::RgbImage::from_vec( 221 | dst_img.width().get(), 222 | dst_img.height().get(), 223 | dst_img.into_vec(), 224 | ) 225 | .unwrap(), 226 | ), 227 | fr::PixelType::U8x4 => DynamicImage::ImageRgba8( 228 | image::RgbaImage::from_vec( 229 | dst_img.width().get(), 230 | dst_img.height().get(), 231 | dst_img.into_vec(), 232 | ) 233 | .unwrap(), 234 | ), 235 | fr::PixelType::U8 => DynamicImage::ImageLuma8( 236 | image::GrayImage::from_vec( 237 | dst_img.width().get(), 238 | dst_img.height().get(), 239 | dst_img.into_vec(), 240 | ) 241 | .unwrap(), 242 | ), 243 | fr::PixelType::U8x2 => DynamicImage::ImageLumaA8( 244 | image::GrayAlphaImage::from_vec( 245 | dst_img.width().get(), 246 | dst_img.height().get(), 247 | dst_img.into_vec(), 248 | ) 249 | .unwrap(), 250 | ), 251 | _ => unreachable!(), 252 | } 253 | } 254 | -------------------------------------------------------------------------------- /wk-image/wrangler.toml: -------------------------------------------------------------------------------- 1 | name = "cotrans-wk-image" 2 | main = "build/worker/shim.mjs" 3 | compatibility_date = '2023-07-09' 4 | compatibility_flags = [] 5 | usage_model = 'unbound' 6 | workers_dev = false 7 | 8 | [[migrations]] 9 | tag = "v1" 10 | new_classes = ["DOImage"] 11 | 12 | [durable_objects] 13 | bindings = [{ name = "doImage", class_name = "DOImage" }] 14 | 15 | [[r2_buckets]] 16 | binding = 'BUCKET_PRI' 17 | bucket_name = 'cotrans-private' 18 | 19 | [build] 20 | command = "cargo install -q worker-build && RUSTFLAGS=\"-Ctarget-feature=+simd128\" worker-build --release . -Zbuild-std=std,panic_abort" 21 | -------------------------------------------------------------------------------- /wkr2/.gitignore: -------------------------------------------------------------------------------- 1 | /dist/ 2 | -------------------------------------------------------------------------------- /wkr2/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "printWidth": 100, 4 | "singleQuote": true, 5 | "bracketSpacing": true, 6 | "insertPragma": false, 7 | "requirePragma": false, 8 | "jsxSingleQuote": false, 9 | "bracketSameLine": false, 10 | "embeddedLanguageFormatting": "auto", 11 | "htmlWhitespaceSensitivity": "css", 12 | "vueIndentScriptAndStyle": true, 13 | "quoteProps": "consistent", 14 | "proseWrap": "preserve", 15 | "trailingComma": "es5", 16 | "arrowParens": "avoid", 17 | "useTabs": true, 18 | "tabWidth": 2 19 | } 20 | -------------------------------------------------------------------------------- /wkr2/README.md: -------------------------------------------------------------------------------- 1 | # Template: worker-r2 2 | 3 | [![Deploy to Cloudflare Workers](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/cloudflare/templates/tree/main/worker-r2) 4 | 5 | A template for interfacing with an R2 bucket from within a Cloudflare Worker. 6 | 7 | Please refer to the [Use R2 from Workers](https://developers.cloudflare.com/r2/data-access/workers-api/workers-api-usage/) documentation when using this template. 8 | 9 | ## Setup 10 | 11 | To create a `my-project` directory using this template, run: 12 | 13 | ```sh 14 | $ npm init cloudflare my-project worker-r2 15 | # or 16 | $ yarn create cloudflare my-project worker-r2 17 | # or 18 | $ pnpm create cloudflare my-project worker-r2 19 | ``` 20 | 21 | > **Note:** Each command invokes [`create-cloudflare`](https://www.npmjs.com/package/create-cloudflare) for project creation. 22 | 23 | ## Getting started 24 | 25 | Run the following commands in the console: 26 | 27 | ```sh 28 | # Next, make sure you've logged in 29 | npx wrangler login 30 | 31 | # Create your R2 bucket 32 | npx wrangler r2 bucket create 33 | 34 | # Add config to wrangler.toml as instructed 35 | 36 | # Deploy the worker 37 | npx wrangler deploy 38 | ``` 39 | 40 | Then test out your new Worker! 41 | 42 | ## Note about access and privacy 43 | 44 | With the default code in this template, every incoming request has the ability to interact with your R2 bucket. This means your bucket is publicly exposed and its contents can be accessed and modified by undesired actors. 45 | 46 | You must define authorization logic to determine who can perform what actions to your bucket. To know more about this take a look at the [Bucket access and privacy](https://developers.cloudflare.com/r2/data-access/workers-api/workers-api-usage/#6-bucket-access-and-privacy) section of the **Use R2 from Workers** documentation 47 | -------------------------------------------------------------------------------- /wkr2/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "dependencies": { 4 | "jose": "^4.14.4", 5 | "zod": "^3.21.4" 6 | }, 7 | "devDependencies": { 8 | "@cloudflare/workers-types": "^4.20230717.1" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /wkr2/src/index.ts: -------------------------------------------------------------------------------- 1 | import { importSPKI, jwtVerify } from 'jose' 2 | import { z } from 'zod' 3 | 4 | export interface Env { 5 | JWT_PUBLIC_KEY: string 6 | JWT_AUDIENCE: string 7 | BUCKET: R2Bucket 8 | } 9 | 10 | const JWTSchema = z.object({ 11 | // files 12 | f: z.array(z.string()).optional(), 13 | // directories 14 | d: z.array(z.string().endsWith('/')).optional(), 15 | // permissions 16 | p: z.array(z.enum(['GET', 'PUT', 'DELETE'])), 17 | }) 18 | 19 | export default { 20 | async fetch(request: Request, env: Env): Promise { 21 | const jwt_public_key = await importSPKI(env.JWT_PUBLIC_KEY, 'ES256') 22 | 23 | const url = new URL(request.url) 24 | const file = url.pathname.slice(1) 25 | const token = url.searchParams.get('t') 26 | 27 | if (token === null) 28 | return new Response(JSON.stringify({ code: 'missing_token' }), { status: 400 }) 29 | 30 | // parse token 31 | let jwt: z.infer 32 | try { 33 | const verified = await jwtVerify(token, jwt_public_key, { 34 | algorithms: ['ES256'], 35 | // issuer: 'wk:gateway:mit', 36 | audience: env.JWT_AUDIENCE, 37 | maxTokenAge: '4h', 38 | }) 39 | jwt = JWTSchema.parse(verified.payload) 40 | if (!jwt.f?.length && !jwt.d?.length) 41 | throw new Error('Missing file or directory') 42 | } 43 | catch (error) { 44 | console.error(String(error instanceof Error ? error.stack : error)) 45 | return new Response(JSON.stringify({ code: 'invalid_token' }), { status: 400 }) 46 | } 47 | 48 | // check permissions 49 | if (!jwt.p.includes(request.method as never)) 50 | return new Response(JSON.stringify({ code: 'forbidden' }), { status: 403 }) 51 | 52 | // check if file is in scope 53 | if ( 54 | (jwt.f?.length && !jwt.f.includes(file)) 55 | && (jwt.d?.length && !jwt.d.some(d => file.startsWith(d))) 56 | ) 57 | return new Response(JSON.stringify({ code: 'forbidden' }), { status: 403 }) 58 | 59 | // handle request 60 | switch (request.method) { 61 | case 'PUT': { 62 | const obj = await env.BUCKET.put(file, request.body) 63 | return new Response(JSON.stringify({ code: 'ok', file, size: obj.size })) 64 | } 65 | 66 | case 'GET': { 67 | const obj = await env.BUCKET.get(file) 68 | 69 | if (obj === null) 70 | return new Response(JSON.stringify({ code: 'not_found' }), { status: 404 }) 71 | 72 | const headers = new Headers() 73 | obj.writeHttpMetadata(headers) 74 | headers.set('etag', obj.httpEtag) 75 | 76 | return new Response(obj.body, { headers }) 77 | } 78 | 79 | case 'DELETE': { 80 | await env.BUCKET.delete(file) 81 | return new Response(JSON.stringify({ code: 'ok', file })) 82 | } 83 | 84 | default: 85 | return new Response(JSON.stringify({ code: 'method_not_allowed' }), { status: 405 }) 86 | } 87 | }, 88 | } 89 | -------------------------------------------------------------------------------- /wkr2/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "module": "ESNext", 5 | "target": "ESNext", 6 | "lib": ["ESNext", "DOM", "DOM.Iterable"], 7 | "types": ["@cloudflare/workers-types"], 8 | "strict": true, 9 | "esModuleInterop": true, 10 | "skipLibCheck": true, 11 | "moduleResolution": "node", 12 | "jsx": "preserve", 13 | "resolveJsonModule": true, 14 | "strictNullChecks": true, 15 | "forceConsistentCasingInFileNames": true, 16 | "paths": { 17 | "@/*": ["*"] 18 | } 19 | }, 20 | "include": ["**/*.ts"], 21 | "exclude": ["dist", "node_modules"] 22 | } 23 | -------------------------------------------------------------------------------- /wkr2/wrangler.toml: -------------------------------------------------------------------------------- 1 | name = "cotrans-wkr2-public" 2 | main = "src/index.ts" 3 | compatibility_date = '2023-07-09' 4 | compatibility_flags = [] 5 | workers_dev = false 6 | usage_model = 'bundled' 7 | 8 | [[r2_buckets]] 9 | binding = 'BUCKET' 10 | bucket_name = 'cotrans-public' 11 | 12 | [placement] 13 | mode = "smart" 14 | 15 | [env.private] 16 | name = "cotrans-wkr2-private" 17 | 18 | [[env.private.r2_buckets]] 19 | binding = 'BUCKET' 20 | bucket_name = 'cotrans-private' 21 | --------------------------------------------------------------------------------